diff --git a/docs/import/20260710/任务卡前端字段变更与路由说明_3.0_to_current.md b/docs/import/20260710/任务卡前端字段变更与路由说明_3.0_to_current.md new file mode 100644 index 0000000..c35aa2f --- /dev/null +++ b/docs/import/20260710/任务卡前端字段变更与路由说明_3.0_to_current.md @@ -0,0 +1,363 @@ +# 任务卡前端字段变更与路由说明(开发共识版) + +> 对比基线:`任务卡前端展示字段表 3.0.xlsx` +> 当前规则基线:2026-07-10 17:50(Asia/Shanghai) +> 业务共识基线:2026-07-10(房型/Rate Code、Parent split、Note/Trace、Rooming List/TA Recorder、人工复核) +> `booking-desk-event.skill` SHA-256:`b5f33e17c642fefc11b41934cdd657ff2df25ea50b544c7a44424c5d3d977192` + +> 文档定位:本文是信息系统 adapter/任务卡开发的确定性交接契约。如项目 reference 中仍存在旧的含混表述,本文中已标记为“已确认业务决策”的规则优先供开发实现,后续再回写上游 skill。 + +## 1. 开发先看结论 + +1. 当前 Agent **没有**为业务结果直接输出 `result_type`、`task_type`、`task_subtype`。普通业务的真实路由键是 `message_events[].event_type`;只有 S10/S99 原生输出 `result_type=source_message_review_notification`。 +2. 三元组应由信息系统 adapter **按每个 `message_events[i]` 派生**,不能在邮件根对象只算一次。一封邮件可以同时生成多张候选卡。 +3. `message_events[]` 是候选事件,不是真实 TaskCard;信息系统仍需做 Case 匹配、Preflight、去重、锁和状态检查后再建卡。 +4. S10、S99 与 `unhandled_current_intents[]` 都是展示路径,不创建业务 TaskCard。 +5. 业务 `Need Manual Review` 与 S99 不是同一种复核:前者是 `business_event_review`,后者是 `main_agent_entry_review`。 +6. 房型或 Rate Code 无法唯一映射时,默认转业务人工复核;唯一例外是 Parent split 中某个 child 的房型原文仅为 `SUITE`,该 child 可保留为 New Booking 候选事件,但不得成为可执行任务。 +7. Parent split 固定产生 **1 个 Parent Group Cancel Booking 候选 + N 个 Child Group New Booking 候选**,不经过 `Allotment Maintenance`。 +8. 每个 Rooming List 目标必须派生一个与该 group/预订明确绑定的 TA Recorder。 +9. 修改已有结构化订单字段/备注走 Update;新增补充事实、要求或安排走 Trace;`Note` 不再开放新任务卡。 +10. 业务人工复核不按原业务类型细分卡型。进入 `manual_review` 后,卡内业务字段全量可编辑;原邮件、证据、系统 ID 和审计字段仍只读。 + +### 1.1 已确认业务决策 + +| 主题 | 最终规则 | 开发不得做的事 | +| --- | --- | --- | +| 房型/Rate Code 歧义 | 默认 `Need Manual Review` | 不得猜 PMS 房型或 Rate Code | +| Parent split + raw `SUITE` | 只对该 child 保留 `room_type_raw=SUITE`,`pms_room_type_code=null`,`requires_downstream_hard_validation=true` | 不得填 `SU1/SU3/SU6`;不得创建可执行任务或写 PMS | +| Parent split | Parent 是 Group Block 取消;children 是 Group Block New Booking | 不得路由到 `Cancel Allotment` 或 `Allotment Maintenance` | +| Rooming List | 每个已确认目标必须同时产生 1 个 Rooming List + 1 个 TA Recorder | 不得按邮件/workbook/sheet 只产生 1 个 TA Recorder | +| 修改备注 vs 新增补充 | 修改已有结构化值走 Update;新增信息走 Trace | 不得用 `Note` 承接普通补充信息 | +| 业务人工复核 | 统一人工复核卡,业务字段可编辑 | 不得依赖 `intended_event_type` 拆分复核卡 | + +当前正式契约以以下文件组合为准: + +- `prompts/main_agent_prompt.md` +- `skills/booking-desk-event/SKILL.md` +- `skills/booking-desk-event/references/00-output-contract.md` +- `skills/booking-desk-event/references/01-current-history-boundary.md` +- `skills/booking-desk-event/references/03-current-content-completeness.md` +- `skills/booking-desk-event/references/02-event-routing-map.md` +- 各业务 reference + +`task_plan.md`、`findings.md`、`progress.md` 是累积工作日志,不是接口契约。`.skill` 包也不包含 Main Agent prompt,部署时两者必须同步。 + +## 2. 信息系统标准三元组 + +下表是本次确认冻结的 **adapter 输出**。其中 `normal_task`、`manual_review` 延续 3.0 的信息系统语义;`source_message_review_notification` 来自当前上游;`unhandled_current_intent` 是为非任务展示新增的下游枚举。 + +`result_type` 是本文和 3.0 表使用的标准名。如信息系统实体内部字段名为 `task_result`,必须做同义归一:`task_result := result_type`;不得当成第四个独立路由字段。信息系统的人工复核判别组合固定为 `task_type=Fallback + task_result=manual_review`,`task_subtype=business_event_review`。 + +| result_type | task_type | task_subtype | 上游判别 | 信息系统具体去向 | 是否建业务卡 | +| --- | --- | --- | --- | --- | --- | +| `source_message_review_notification` | `Message Notification` | `S10` | 根 `route_code=S10` | 源邮件查看通知组件 | 否 | +| `source_message_review_notification` | `Message Notification` | `S99` | 根 `route_code=S99` | 源邮件查看通知组件 + 入口复核信息 | 否 | +| `normal_task` | `New Booking` | `new_fit_reservation` | `event_type=New Booking` 且 `extracted_fields.booking_object_type=FIT Reservation` | New Booking 卡(FIT) | 字段校验通过后为候选卡 | +| `normal_task` | `New Booking` | `new_group_block` | `event_type=New Booking` 且 `extracted_fields.booking_object_type=Group Block` | New Booking 卡(Group Block) | 字段校验通过后为候选卡 | +| `normal_task` | `New Booking` | `new_allotment_control_block` | `event_type=New Booking` 且 `extracted_fields.booking_object_type=Allotment / Control Block` | New Booking 卡(Allotment / Control Block) | 候选卡,Preflight 后 | +| `normal_task` | `Update Booking` | `update_booking_amendment` | `event_type=Update Booking / Amendment` | Update Booking 卡;修改项另外展示 | 候选卡,Preflight 后 | +| `normal_task` | `Cancel Booking` | `cancel_fit_reservation` | `event_type=Cancel Booking` 且 `extracted_fields.cancel_object_type=fit_reservation` | Cancel Booking 卡(FIT) | 字段校验通过后为候选卡 | +| `normal_task` | `Cancel Booking` | `cancel_group_block` | `event_type=Cancel Booking` 且 `extracted_fields.cancel_object_type=group_block` | Cancel Booking 卡(Group Block) | 字段校验通过后为候选卡 | +| `normal_task` | `Cancel Booking` | `linked_parent_release_after_child_split` | `event_type=Cancel Booking`;`cancel_object_type=group_block`;`relationship_type` 同名 | Cancel Booking 卡(Parent Group) | 候选卡,必须下游硬校验 | +| `normal_task` | `Cancel Allotment` | `cancel_allotment_control_block` | `event_type=Cancel Allotment` | Cancel Allotment 卡 | 候选卡,Preflight 后 | +| `normal_task` | `Voucher Received` | `lian_tai_credit_voucher` | `event_type=Voucher Received` | Voucher Received 卡 | 候选卡,Preflight 后 | +| `normal_task` | `Payment Evidence` | `payment_evidence` | `event_type=Payment Evidence` | Payment Evidence 卡 | 候选卡,Preflight 后 | +| `normal_task` | `Rooming List` | `rooming_list` | `event_type=Rooming List` | Rooming List 卡 | 候选卡,Preflight 后 | +| `normal_task` | `Amend Group Code` | `amend_group_code` | `event_type=AMEND GROUP CODE` | Amend Group Code 卡 | 候选卡,Preflight 后 | +| `normal_task` | `Trace / Reservation Notes` | `extra_bed` | `event_type=Trace` 且 `extracted_fields.trace_subtype=extra_bed` | Trace 卡(Extra Bed) | 候选卡,Preflight 后 | +| `normal_task` | `Trace / Reservation Notes` | `general_request` | `event_type=Trace` 且 `extracted_fields.trace_subtype=general_request` | Trace 卡(General Request) | 候选卡,Preflight 后 | +| `normal_task` | `TA Recorder` | `maintain_ta_recorder` | `event_type=TA RECORDER`;与同目标 Rooming List 关联 | TA Recorder 卡(从对应 Rooming List 派生) | 每目标必产生 1 张;Preflight 后 | +| `normal_task` | `Invoice Generation` | `invoice_generation` | `event_type=Invoice Generation` | Invoice Generation 卡 | 候选卡;业务字段待冻结 | +| `normal_task` | `Invoice Received` | `invoice_received` | `event_type=Invoice Received` | Invoice Received 卡 | 候选卡;业务字段待冻结 | +| `normal_task` | `Payment Notice` | `payment_notice` | `event_type=Payment Notice` | Payment Notice 卡 | 候选卡;业务字段待冻结 | +| `normal_task` | `Manual RateCode` | `manual_rate_code` | `event_type=Manual RateCode` | Manual RateCode 卡 | 候选卡;业务字段待冻结 | +| `manual_review` | `Fallback` | `business_event_review` | `event_type=Need Manual Review` 且 `manual_review.review_record_type=business_event_review` | 统一业务人工复核卡;业务字段全量可编辑 | 是,但不得执行业务写入 | +| `unhandled_current_intent` | `Unhandled Current Intent` | `requires_business_approval_or_unsupported_task_card` | 每个 `unhandled_current_intents[i]` | 邮件详情内未覆盖意图展示块 | 否 | + +说明: + +- `task_type` 保留 3.0 已存在的信息系统卡名;上游 `event_type` 的大小写和空格必须按当前契约原值匹配。 +- New Booking 的 FIT/Group 对象枚举和普通 Cancel 的 `extracted_fields.cancel_object_type` 按本文第 5 节直接冻结;不能靠前端猜测。 +- Update 的多种修改可以同时存在,因此统一使用一个卡 subtype;不得再用单个 action token 决定卡型。 +- 人工复核统一使用 `manual_review + Fallback + business_event_review`。前端只需通过 `result_type=manual_review`(或内部别名 `task_result=manual_review`)切换到全量业务字段可编辑模式,不再需要 `manual_review.intended_event_type`。 +- Invoice、Payment Notice、Manual RateCode 已进入支持事件目录,但详细 payload 尚未完整冻结。首版只能使用通用事件头、目标、证据和人工复核字段。 + +### 2.1 Parent split 的确定性事件集 + +Parent split 不是 `Allotment Maintenance`,也不是一张复合卡。必须在同一封邮件的 `message_events[]` 中产生: + +1. 每个 child group 一个 `New Booking`: + - `booking_object_type=Group Block` + - adapter 三元组为 `normal_task + New Booking + new_group_block` + - `case_keys.group_code=` +2. 一个 parent group `Cancel Booking` 候选: + - `cancel_object_type=group_block` + - adapter 三元组为 `normal_task + Cancel Booking + linked_parent_release_after_child_split` + - `case_keys.group_code=` + - `child_group_codes[]` 包含全部 children,`related_source_event_indices[]` 指向全部 child New Booking 事件 + - `requires_downstream_hard_validation=true` + +Parent 取消事件只是候选。下游确认 parent group、全部 child 关系和 PMS 当前状态前,不得创建可执行取消任务,不得写入 PMS。 + +### 2.2 Parent split 的 generic `SUITE` 唯一例外 + +只有当一个 child 的唯一不确定项是房型原文 `SUITE` 时,该 child 才可继续输出 New Booking 候选事件: + +```json +{ + "extracted_fields": { + "room_type_raw": "SUITE", + "pms_room_type_code": null + }, + "requires_downstream_hard_validation": true +} +``` + +- 不得猜测或填写 `SU1`、`SU3`、`SU6`。 +- 下游确认具体 PMS 房型代码前,该 child 只是不可执行候选,不得写 PMS。 +- 校验失败时,只将该 child 转业务人工复核;不得阻塞或丢弃同邮件中其他已合格的 child 事件。 +- Rate Code 不唯一不在此例外内;仍必须转业务人工复核。 + +## 3. Adapter 路由顺序 + +```text +1. 如果根 result_type == source_message_review_notification: + - route_code 必须是 S10 或 S99;映射通知组件;不遍历业务卡。 + +2. 否则按业务根处理: + - 校验五个根字段存在; + - 遍历 message_events[],每个 event 独立计算一组三元组; + - 遍历 unhandled_current_intents[],每个 item 生成一个展示块; + - case_candidates[] 和 extraction_warnings[] 不参与任务卡路由。 + +3. 任意稳定判别字段缺失或出现未知 event_type: + - 对该 event fail closed,记录接口契约错误并展示源邮件; + - 不得按房量、关键词、附件名等在前端重新执行 AI 业务判断; + - 不得为该无效 event 自动创建 TaskCard;根结构仍合法时,其他合法 sibling events 继续独立处理。 +``` + +关键判别: + +- New Booking:必须读取 `extracted_fields.booking_object_type`。 +- Update:固定 subtype=`update_booking_amendment`;具体修改用 action 数组或 `before_after[]` 展示。 +- Cancel:先判断 parent split 的 `relationship_type`;命中时 `cancel_object_type` 必须是 `group_block`,再处理其他普通 cancel object。 +- Trace:只允许 `extracted_fields.trace_subtype=extra_bed|general_request`。 +- Need Manual Review:必须同时检查 `manual_review.review_record_type=business_event_review`。 +- S99 的 `main_agent_entry_review` 只展示在 S99 通知中,不能创建 Fallback 业务卡。 +- Unhandled Current Intent:`text_raw` 和 `visible_message` 都必须展示;前者是证据原文,后者是给用户的中文提示。 + +### 3.1 “契约错误”和“人工复核”不再混用 + +| 情况 | 系统处理 | 该项自动业务卡数 | +| --- | --- | --- | +| 未知/禁用 `event_type`,或已声明某路由但必填判别字段缺失 | 记录 `adapter_contract_error`,保留并展示源邮件;该 event 不转业务复核 | 0 | +| 业务类型已识别,但房型/Rate Code/目标/证据存在业务歧义 | 生成统一 `manual_review + Fallback + business_event_review` 卡 | 1 张人工复核卡,0 张可执行卡 | +| Parent split 的业务意图已确认,但 parent group 或 parent-child 绑定无法确认 | 该 parent 取消转统一业务人工复核;合法 child events 仍独立处理 | parent 可执行卡 0;人工复核 1 | +| event 声明 `linked_parent_release_after_child_split`,但 `parent_group_code/child_group_codes/related_source_event_indices` 等契约字段缺失 | 这是 payload 契约错误,不是业务歧义;记录错误并拒绝该 event | 0 | + +因此,开发文档中不再使用“0 卡/复核”这类二选一表述。契约错误和业务不确定必须按上表分开。 + +## 4. 3.0 之后已发生的字段变化 + +### 4.1 已新增,开发必须消费 + +| 字段/结构 | 变化 | 开发动作 | +| --- | --- | --- | +| `source_message` | 所有合规结果新增源邮件身份 | 根级保存并用于原邮件跳转 | +| `source_message.source_message_id` | 升级为非空必填,不得猜测 | 缺失视为上游/基础设施错误 | +| `message_events[]` | 邮件级多事件数组 | 每个 item 独立路由;不能一封邮件只建一张卡 | +| `case_candidates[]` | 新根字段 | 保留;item schema 未冻结,暂不消费 | +| `extraction_warnings[]` | 新根字段/语义收窄 | 只显示解析/OCR/抽取告警,不建卡 | +| `unhandled_current_intents[]` | 新增第五个业务根字段 | 每项同时展示 `visible_message` 和 `text_raw`,永不建卡 | +| `message_events[].event_role` | 新增事件角色 | 审计展示;当前常见值 `travel_agent_request` | +| `message_events[].current_or_history` | 新增证据边界 | 正常新事件必须为 `current` | +| `message_events[].source_event_index` | 新增批次内关系索引 | 仅用于事件关联,不是 TaskCard ID | +| `case_keys.reservation_number` / `block_code` | 通用目标 key 扩展为四键 | 与 group/confirmation 一起固定保留 | +| `file_references[]` | 通用文件引用 | 与 `attachments[]` 分开归一,缺失补 `[]` | +| `context_used` | 新增历史/系统证据审计对象 | 只做审计和硬校验,不做卡型推断 | +| `related_source_event_index` | 新增单来源关系 | linked Trace 等场景使用 | +| `related_source_event_indices[]` | 新增多来源关系 | parent release 关联多个 child 时使用 | +| `related_event_type` | 新增关联事件类型 | 关系展示 | +| `relationship_type` | 新增关系类型 | `linked_trace`、`linked_parent_release_after_child_split` 或 `derived_ta_recorder_from_rooming_list` | +| `requires_downstream_hard_validation` | 新增下游硬校验开关 | 为 true 时禁止自动执行 | +| `extracted_fields.room_type_raw` | 将房型原文与 normalized/PMS code 分开 | 始终保留原文;不再用单一 `room_type` 同时表示原文和标准值 | +| `extracted_fields.pms_room_type_code` | 从默认必填改为“唯一映射后才可填” | 歧义时保持 `null`;Parent split raw `SUITE` 例外中禁止填猜测值 | +| `extracted_fields.nights` | 住期新增晚数 | 与 arrival/departure 一起显示/校验 | +| `extracted_fields.date_evidence` | 新增酒店/行程/动作/sheet 原始日期证据 | 复核区展示;action date 不是入住日期 | +| `extracted_fields.trace_items[]` | Trace 新增结构化明细 | 同一目标仍是一张 Trace,不按 item 拆卡 | +| `trace_items[].category/text_raw/service_date/service_period_raw/pax/notify_departments` | Trace 明细字段新增 | 按原顺序展示 | +| `extracted_fields.requires_rate_update` | Extra Bed 新增费率更新意图 | 只表示意图,不计算价格 | +| parent release/cancel 一组字段 | 新增 parent/child、release reason、候选标记 | 固定映射 Parent Group Cancel Booking 特殊候选卡,使用硬校验 | +| TA Recorder 派生关系 | 每个 Rooming List 目标必须派生 | `related_source_event_index=`;`related_event_type=Rooming List`;`relationship_type=derived_ta_recorder_from_rooming_list`;两者 `case_keys` 必须一致 | +| QBD/LianTai row evidence | 新增 sheet/row/highlight/status/raw date/room/price 等证据 | 复核区展示;一个有效行一个事件 | +| `rate_code_result.manual_price_reason_code` | 手工价原因新增 | 手工价提示 | +| `rate_code_result.price_evidence[]` | 价格证据新增 | 审计/复核展示 | +| Fix Charge item 扩展字段 | 新增 amount/currency/unit/quantity/total/raw/evidence/follow-up | 保留但不自动调用 TBD 工具 | +| `additional_operations[]` | 新增后续操作意图 | 只显示 pending,不直接执行 | +| S10/S99 notification 结构 | 新增 route/assessment/notification/user-decision | 统一源邮件查看组件 | + +### 4.2 已修改或迁移 + +| 3.0 字段/枚举 | 当前处理 | 开发动作 | +| --- | --- | --- | +| 根级/卡级旧字段路径 | 业务字段现在位于 `message_events[i]` | normalizer 加事件数组前缀 | +| `result_type/task_type/task_subtype` | 业务 Agent 不再原生输出 | adapter 逐 event 派生 | +| 信息系统内部 `task_result` | 如存在,仅是 `result_type` 的别名 | normalizer 统一为 `task_result := result_type` | +| `Update Booking` 多个 action 作为 subtype | 一个 Update 可多 action | subtype 固定 `update_booking_amendment`,动作另存数组/明细 | +| 修改已有备注/结构化字段 | `Update Booking / Amendment` | 使用 `update_actions[]` 和 `before_after[]` 展示改前/改后 | +| 新增补充事实、要求、安排 | `Trace` | 使用 `trace_items[]`,不转 `Note` | +| `cancel_allotment_control_block` 属于 Cancel Booking | 当前有独立 `Cancel Allotment` | 迁移到新卡 | +| `bank_transfer_slip` 属于 Voucher Received | 当前银行/现金/交易凭证是 `Payment Evidence` | 迁移到新卡 | +| Rooming List 四个 subtype | 当前是同一名单事件的证据同义词 | 统一 subtype=`rooming_list`,原文留 evidence | +| Rooming List → TA Recorder | 从“可能派生”收口为“每目标必须派生” | 每个 target 产生一对关联事件,保留同一目标键和 Rooming List 关系 | +| Parent split 路由 | 旧文档使用 release/cancel 混合描述 | 固定 child Group New Booking + parent Group Cancel Booking;不进入 Cancel Allotment/Allotment Maintenance | +| 房型/Rate Code 不唯一 | 旧文档写成“人工复核或 downstream validation” | 默认人工复核;只保留 Parent split child raw `SUITE` 这一个硬校验例外 | +| 旧 `extracted_fields.room_type` | 拆分为房型原文、normalized 值和 PMS code | 原文迁移到 `room_type_raw`;PMS code 仅在唯一映射时填写 | +| `Trace / Reservation Notes` 上游 task name | 当前 `event_type=Trace` | adapter 映回旧信息系统卡名 | +| `linked task` Trace subtype | 当前是 `relationship_type=linked_trace` | subtype 仍必须是 extra_bed/general_request | +| `parent_source_event_index` | 当前改用通用 related 字段 | 迁移到 `related_source_event_index/indices` | +| `post_confirm_action` | 当前改为 `post_confirmation_intent` | 字段改名;仍是 pending intent | +| Rate Code 固定下拉全集 | 当前 reference 仅为 Typical/已知规则 | 不得硬编码文档示例为封闭枚举;使用配置中心或可编辑文本 | +| Fix Charge `charge_type=additional_charge` | 当前为 `fixed_charge` | 更新枚举 | +| Fix Charge `pricing_mode=unit_price` | 当前为 `unit` | 更新枚举 | +| 业务人工复核只展示 reason/known fields | 当前固定九字段结构 | 完整显示 missing/blocking/conflict/action/evidence/known fields;业务字段全量可编辑 | +| S10 状态名 `no_booking_action_detected` | 值兼容保留,但语义改为“未匹配支持事件” | 必须展示原邮件并要求用户决定,不能自动关闭 | + +### 4.3 废弃、禁止或暂不开放 + +| 旧值/组合 | 状态 | 替代 | +| --- | --- | --- | +| `informational_message + Message Notification + thank_you/fyi/...` | 废弃业务卡路由 | 裸礼貌/裸 FYI → S10;具体预订信息 → Trace;输入不足 → S99 | +| `Fallback` 的旧 routing subtype | 废弃为路由主键 | S10、S99 或业务 Need Manual Review 三分流 | +| `manual_review + 各业务 task_type + manual_review` | 废弃 | 统一 `manual_review + Fallback + business_event_review`;不再新增 intended event type | +| `new_booking` | 废弃直接路由 | 必须归一成 FIT/Group/Allotment 三类之一 | +| `new_allotment` | 兼容 alias | 归一为 `new_allotment_control_block` | +| `update_trace_or_guest_request` | 废弃 | 独立 Trace | +| `update_group_block_linkage` | 废弃模糊值 | old→new code 用 Amend Group Code;parent split 用 child New + parent Cancel | +| Trace subtype=`linked task` | 废弃 | 使用 relationship 字段 | +| Trace subtype=`任务卡展示编辑矩阵` | 3.0 数据污染 | 删除 | +| `No Action`、`S000`、`S999` | 非法 | 只允许 S10/S99 | +| `Note` | 禁用;仅作为旧契约历史文字 | 普通补充信息统一 Trace;不开放新卡 | +| `Allotment Maintenance` | 禁用;无独立 producer/schema/任务卡 | 修改已有 Allotment/Control Block 走 `Update Booking / Amendment` + `update_allotment_control_block`;Parent split 走 child New + parent Cancel | +| Fix Charge 独立任务 | 非法 | 只作为 New/Update 内嵌字段和后续意图 | + +## 5. 开发可直接收口的技术契约(无需再等产品确认) + +### 5.1 路由和标识字段 + +| 项目 | 必须实现的固定规则 | +| --- | --- | +| New Booking 对象判别 | 强制 `extracted_fields.booking_object_type`,枚举固定为 `FIT Reservation / Group Block / Allotment / Control Block` | +| Cancel Booking 对象判别 | 强制 `extracted_fields.cancel_object_type=fit_reservation\|group_block`;普通 Allotment 取消必须使用 `Cancel Allotment`;Parent split 的 parent 固定为 `group_block` | +| Update 修改项 | 冻结 `extracted_fields.update_actions[]`,允许多值;它只用于展示修改项,不用作卡 subtype | +| `result_type/task_result` | 对外契约使用 `result_type`;信息系统内部若使用 `task_result`,只做同义别名归一 | +| 业务人工复核 | 固定 `result_type/task_result=manual_review`、`task_type=Fallback`、`task_subtype=business_event_review`,不增加 `intended_event_type`;业务字段全量可编辑,证据/审计/系统字段只读 | +| 事件关系 ID | `source_event_index/related_*` 仅在本次 Agent 输出内建立关系,绝不是 TaskCard ID 或 PMS ID | + +### 5.2 字段归一 + +| 项目 | 必须实现的固定规则 | +| --- | --- | +| `attachments[]` / `file_references[]` | 两个字段分开保留;缺失时 normalizer 补 `[]`,不得合并成一个字段 | +| `manual_review` | 普通事件缺失时补 `null`;`Need Manual Review` 必须包含完整九字段对象,不完整则契约错误 | +| `case_keys` | 固定保留 `group_code/confirmation_number/reservation_number/block_code` 四键,无值用 `null`,不得猜测 | +| `unhandled_current_intents[]` | 每项必须保留并展示 `text_raw`、`visible_message`、`requires_user_decision=true`;不建卡 | +| `source_message.source_message_id` | 非空必填;缺失时整个根结果按上游/基础设施错误拒绝,不得创建卡或 S10/S99 通知 | +| `case_candidates[]` / `extraction_warnings[]` | item schema 冻结前只保留/展示,不参与任务创建 | + +### 5.3 执行安全 + +| 项目 | 必须实现的固定规则 | +| --- | --- | +| `requires_downstream_hard_validation=true` | 该 event 只能停留在候选/待确认状态;禁止变成可执行 TaskCard,禁止写 PMS | +| 房型/Rate Code 歧义 | 默认转业务人工复核;不得由前端、adapter 或配置默认值猜测 | +| Parent split raw `SUITE` | 仅保留 raw + `null` PMS code + hard validation;失败后仅转该 child 人工复核 | +| Parent split parent cancel | 必须检查 parent group、全部 child relation、去重/锁/当前 PMS 状态;候选事件不等于已取消 | +| 未知/禁用 event | 逐 event fail closed,记录 `adapter_contract_error`,不自动转业务人工复核,不影响其他合法 sibling events | +| 最终输出 | 直接解析结构化 JSON 对象;不依赖 JSON 文件、包装层、下载链接或 output mode | + +### 5.4 尚未冻结的专属编辑区 + +Invoice Generation、Invoice Received、Payment Notice、Manual RateCode 的专属 `extracted_fields` 仍不完整。Cancel、Rooming List、TA Recorder 也没有完整覆盖旧 3.0 的全部专属字段路径。在新 schema 冻结前: + +- 允许开发通用候选卡头、目标、证据和人工复核区。 +- 不得自行发明专属字段、枚举或必填条件。 +- 建议新增 `schema_version`;当前先以本文日期和 skill SHA-256 锁定联调基线。 + +## 6. 明确的非法组合与 fail-closed 规则 + +以下情况必须拒绝自动建卡: + +- 在邮件根只生成一个三元组,忽略多个 `message_events`。 +- S10/S99 创建业务 TaskCard。 +- 已匹配支持事件后仍输出或转换成 S99。 +- `unhandled_current_intents` 创建 TaskCard。 +- 清楚但不支持的业务意图被转换成 Need Manual Review。 +- history-only 内容创建事件、Trace 或未覆盖意图。 +- 多个 group code 塞进一个 event。 +- bank slip 继续进入 Voucher Received。 +- 控房/配额取消继续进入普通 Cancel Booking。 +- Parent split 被当成普通 Update、`Allotment Maintenance` 或 `Cancel Allotment`。 +- Parent split 的 child 不使用 `New Booking + Group Block`,或 parent 不使用 `Cancel Booking + group_block`。 +- Parent split 的 parent cancel candidate 被视为已取消成功。 +- 普通房型或 Rate Code 不唯一时仍创建 normal task;Parent split raw `SUITE` 例外除外。 +- Parent split raw `SUITE` 例外中填写 `SU1/SU3/SU6`,或在硬校验前创建可执行任务/写 PMS。 +- Extra Bed 被当成 Update 或房量。 +- Trace subtype 不是 `extra_bed/general_request`。 +- `trace_items[].category` 被拆成多张任务卡。 +- 修改已有结构化备注被路由到 Trace,或新增补充信息被路由到 Update/Note。 +- 每个 Rooming List 目标未派生对应 TA Recorder,或两者 target/关系字段不一致。 +- `Note` 或 `Allotment Maintenance` 在没有新 schema 的情况下建卡。 +- 缺 `source_message_id` 时仍接受任何“合规”结果、创建业务卡或生成 S10/S99 通知;该情况必须按上游/基础设施错误处理。 +- 把 `source_event_index/related_*` 当真实 TaskCard ID。 +- 根据 Fix Charge 的 TBD 工具名或 payment intent 自动执行外部写入。 + +## 7. 最低验收场景 + +| 场景 | 预期 | +| --- | --- | +| 只有 Thanks / Noted | 1 个 S10 源邮件通知,0 张业务卡 | +| `see attached` 且附件不可取得,无法判断方向 | 1 个 S99 源邮件通知,0 张业务卡 | +| Payment Evidence + “余款能否入住时支付” | 1 张 Payment Evidence 候选卡 + 1 个未覆盖意图展示块 | +| 上述未覆盖意图展示块 | 同时显示 `visible_message` 和 `text_raw`;不创建 TaskCard | +| Update + Meeting + Extra Bed,同一目标 | 1 张 Update 卡 + 1 张合并 Trace 卡;Trace subtype=general_request | +| Rooming List 含两个目标 group | 每目标 1 张 Rooming List + 1 张 TA Recorder,共 4 张候选卡;每对 `case_keys` 一致且 TA Recorder 关联回对应 Rooming List | +| Parent Group 拆 2 个 Child Group | 2 张 `New Booking/new_group_block` + 1 张 Parent Group `Cancel Booking/linked_parent_release_after_child_split`;parent 必须 hard validation | +| Parent split 中 1 个 child 房型仅写 `SUITE` | 该 child 仍输出 New Booking 候选;保留 `room_type_raw=SUITE`,PMS code 为 `null`,hard validation=true;其他 child 不受阻塞 | +| 非 Parent split 的 generic `SUITE`,或任意场景 Rate Code 无法唯一映射 | 1 张统一业务人工复核卡,0 张可执行业务卡;不填猜测值 | +| 同一 Update 改日期、房型、价格 | 1 张 Update 卡,多个修改项;不能因单值 subtype 丢字段 | +| 将订单中已有备注 A 修改为 B | Update Booking,保留 before/after | +| 当前邮件新增“客人 20:00 到店” | Trace/general_request,不是 Update 或 Note | +| LianTai Credit Voucher | Voucher Received,不是 Payment Evidence | +| bank transfer / cash deposit receipt | Payment Evidence,不是 Voucher Received | +| 已匹配 Update 但附件不可读 | 业务 Fallback 人工复核卡,不是 S99 | +| 任意业务 `Need Manual Review` | adapter 输出 `manual_review + Fallback + business_event_review`(或内部 `task_result=manual_review`);业务字段全量可编辑,证据/审计字段只读 | +| 已匹配支持事件但参数不安全,同时还有清楚但不支持的当前意图 | 1 张业务 Fallback 人工复核卡 + 1 个未覆盖意图展示块;不得因复核分支提前返回而丢失展示项 | +| 纯付款政策询问且无支持事件 | S10,不是 Trace、Need Manual Review 或未覆盖意图数组 | +| 已知但当前禁用的 `Note` / `Allotment Maintenance`,或真正未知/拼写错误的 event type | 该 event 记录 `adapter_contract_error`,0 张自动业务卡,保留源邮件;其他合法 sibling events 仍继续处理 | + +## 8. 实施顺序 + +1. 先实现根结构分流和逐事件循环。 +2. 实现本文件的三元组表和 fail-closed 校验。 +3. 落地统一业务人工复核卡和全量业务字段可编辑模式。 +4. 实现 Parent split 事件集、generic `SUITE` 例外和逐 child 隔离失败。 +5. 实现 Rooming List → TA Recorder 强制派生关系。 +6. 新增 S10/S99 源邮件通知与 `unhandled_current_intents` 展示组件,同时展示 `visible_message/text_raw`。 +7. 迁移 Cancel Allotment、Payment Evidence、Invoice、Manual RateCode 等卡型,更新 Trace、日期证据、Fix Charge 和手工价字段。 +8. 尚未冻结的专属 schema 只开发通用候选卡,不自行发明字段。 +9. 用第 7 节场景做 adapter 单元测试和端到端回归。 + +## 9. 证据位置 + +- 当前输出与事件结构:`skills/booking-desk-event/references/00-output-contract.md` +- 完整覆盖与未覆盖意图:`skills/booking-desk-event/references/03-current-content-completeness.md` +- 路由顺序、冲突和多事件拆分:`skills/booking-desk-event/references/02-event-routing-map.md` +- 业务复核/S10/S99 边界:`skills/booking-desk-event/references/90-manual-review.md` +- Update 多动作:`skills/booking-desk-event/references/11-update-booking.md` +- Cancel / Cancel Allotment:`skills/booking-desk-event/references/12-cancel-booking.md` +- Voucher / Payment Evidence:`skills/booking-desk-event/references/13-voucher-payment.md` +- Trace schema:`skills/booking-desk-event/references/16-trace-notes.md` +- Parent split:`skills/booking-desk-event/references/31-allotment-control-block.md` +- 房型/Rate/Fix Charge/日期:`skills/booking-desk-event/references/50-room-type-mapping.md` 至 `54-stay-date-parsing.md` diff --git a/docs/import/20260710/归档/prompts/main_agent_prompt.md b/docs/import/20260710/归档/prompts/main_agent_prompt.md new file mode 100644 index 0000000..8405363 --- /dev/null +++ b/docs/import/20260710/归档/prompts/main_agent_prompt.md @@ -0,0 +1,279 @@ +# 预订邮件 Main Agent Prompt + +你是酒店预订邮件 Main Agent。你负责判断一封新邮件是否需要进入预订部业务处理,并把当前邮件、附件、历史证据和系统上下文整理成可交给 `booking-desk-event` skill 的素材包。 + +你不是业务裁判。不要判断房型映射、Rate Code、Voucher 视觉细节、Rooming List 文件内部字段、最终 Case 是否存在、任务是否可执行、Payment 是否确认、Opera/PMS 写入、Invoice 或 Receipt。 + +你只输出结构化 JSON 参数对象,不输出解释性自然语言,不把最终结果生成为文件。 + +## 1. 职责 + +你负责: + +- 判断当前邮件是否包含新的业务动作、与具体预订对象相关的当前补充业务信息、当前附件、图片、PDF、表格、OCR、文件链接,或明确继续处理指令。补充信息即使只是告知,也可以匹配 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. 当前邮件优先 + +只有当前新邮件可以触发业务事件。 + +处理正文、附件和历史前,必须先保存 `source_message`。对于合规的 `S10`、`S99` 和业务 skill 输出,`source_message.source_message_id` 必须是上游提供的非空值;subject、from、cc、received_at 等其他元数据不可得时使用 `null` 或空数组。不得猜测或生成 `source_message_id`。如果上游没有提供该 ID,则属于本业务路由契约之外的输入或基础设施错误,不能输出一个声称合规的 `S10` 或 `S99`。 + +当前素材包括: + +- `body_current` 中的新请求,以及与具体预订对象相关的补充事实、安排、要求或备注。 +- 当前附件、inline image、PDF、spreadsheet、文件链接、OCR 和解析表格。 +- 当前邮件明确继续上文并要求处理,例如 `please proceed`、`see attached`、`please update as attached`。 + +Main Agent 只判断当前邮件是否匹配 `00-output-contract.md` 列出的支持业务事件,不判断酒店用户是否需要回复或进行其他处理。输入足以理解但没有匹配支持事件时,输出 `S10`,不调用 `booking-desk-event`。只有匹配到至少一个支持事件时,才继续形成业务素材包。 + +选择最终路由前,必须按 `03-current-content-completeness.md` 完成一次当前内容盘点。不得因为已经识别到一个支持事件,就停止读取同邮件剩余正文、当前附件或 OCR。每项有业务意义的当前内容都必须进入 `candidate_events`、内部 `unknowns`、S10 或 S99;不能只保留在 `body_current` 后静默丢弃。 + +Trace 的触发不要求当前文本包含明确动作词。Meeting、meal、arrival notice、room preference、payment information 或其他具体预订补充信息,即使只是 FYI 或单纯告知,只要能绑定目标且不属于主任务核心参数,也作为 `Trace` 候选。只有 `Thanks`、`Noted`、`Received`、裸 `FYI` 等没有具体预订业务内容的文字不匹配 Trace。 + +历史邮件、转发内容、引用线程和 `body_thread` 只能作为证据,用来补充目标对象、旧值、新旧关系、供应商上下文或 parent allocation 背景。 + +## 3. 历史查询 + +只有同时满足以下条件才查询历史: + +- 当前邮件已经匹配至少一个支持业务事件。 +- 当前素材无法唯一绑定目标对象。 + +历史可以补充: + +- `group_code` +- confirmation / reservation number +- 客人姓名 + 入住日期 +- amendment 所需旧值 +- parent allocation 上下文 +- Trace / Guest Request 的最近目标 + +历史查询后仍不能唯一绑定目标时,必须交给 `booking-desk-event` 输出业务级 `Need Manual Review`。只有输入不足、连是否匹配支持事件都无法判断时才输出入口结果 `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": [] +} +``` + +信息不可得时用 `null`、空数组或明确状态,不要猜。 + +需要酒店进行价格、退款、减免、账期、付款政策、合同条件或其他业务审批的询问,以及其他意图清楚但当前任务目录不支持的业务内容,不得伪装成 Trace。只要同邮件已经匹配至少一个支持事件,就在 `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` 必须至少包含一个 `00-output-contract.md` 列出的支持业务事件。输入可理解但 `candidate_events` 为空时输出 `S10`;输入不足以判断候选事件时输出 `S99`。 + +当 `candidate_events` 非空且 `unknowns` 同时非空时,必须把两者一起交给 `booking-desk-event`。业务 skill 将支持事件输出到 `message_events`,并把 `unhandled_current_business_content` 一对一规范化到最终 `unhandled_current_intents`;Main Agent 不得删减或把它们改成 S10、Trace 或人工复核。 + +当当前素材包含 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. 事件拆分 + +- 一个主要业务动作对应一个事件;Trace 按下述同一目标合并规则处理。 +- 一个事件只对应一个主要目标对象;同一目标可以同时有主事件和一个按目标合并后的 linked Trace。 +- 多个 `group_code` 不得放进数组型 `case_keys.group_code`。 +- QBD/LianTai 表格按当前有效行拆分。 +- QBD/LianTai 当前附件中业务列标黄/高亮的行全部按当前有效行拆分;只有序号列、标题、说明区或装饰单元格上色,不单独触发事件。 +- Parent-to-child allocation creation 按 child `group_code` 拆分。 +- Parent-to-child allocation creation 还必须保留 parent group、parent original room summary 和 child group 列表,用于 `booking-desk-event` 额外输出 parent release/cancel 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 Trace、业务人工复核或 `unknowns` 承接一次;HTML/plain MIME alternatives 和相同 OCR 内容必须去重。 +- 一个连续请求跨多句话时保持为一个意图;互相独立的请求按当前证据顺序分别进入事件或 `unknowns`。 + +已经匹配支持业务事件但无法安全拆分时,将当前原文、候选边界和不确定点写入素材包,并交给 `booking-desk-event` 输出业务级 `Need Manual Review`。只有输入不足、无法判断是否匹配任何支持事件时才输出 `S99`。 + +## 7. 内置结果 + +输入可理解但未匹配当前 Agent 支持的业务事件时输出 `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 +} +``` + +当前输入不足,无法判断是否匹配支持业务事件时输出 `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 负责识别 New Booking、Update、Cancel、Voucher、Rooming List、Amend Group Code、Trace、TA Recorder、Invoice、Manual RateCode 和 Need Manual Review,并把混合邮件中的未覆盖当前意图规范化到业务输出展示字段。 + +之后由信息系统负责 Case 匹配、Preflight、真实任务创建、状态机、外部写入和 Receipt。 + +## 9. 最终输出交付 + +- 最终结果必须作为当前调用的结构化 JSON 参数对象直接返回,不得作为 JSON 字符串、Markdown 代码块或文件返回。 +- 业务结果的根对象必须直接使用 `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 对象原样作为最终参数返回,不得二次序列化、转存或包装成文件。 +- 业务结果必须保留 `booking-desk-event` 返回的顶层 `unhandled_current_intents`;不得把它删掉、合并进 `extraction_warnings` 或藏入事件 excerpt。 + +以上限制只针对最终处理结果,不限制输入附件处理。Excel、PDF、图片等输入附件仍可下载、解析和读取;`attachments`、`file_references` 可以继续作为输入证据保留在 JSON 事件中,但不得用它们代替最终 JSON 参数对象。即使事件很多或 JSON 很长,也不得主动将结果改为文件输出。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/SKILL.md b/docs/import/20260710/归档/skills/booking-desk-event/SKILL.md new file mode 100644 index 0000000..2442edd --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/SKILL.md @@ -0,0 +1,147 @@ +--- +name: booking-desk-event +description: Use when processing hotel booking desk emails, attachments, images, PDFs, spreadsheets, OCR, or message threads to identify reservation operation events and preserve unhandled current business intents for display, including New Booking, Update Booking, Cancel Booking, Voucher Received, Payment Evidence, Rooming List, Amend Group Code, Trace or Reservation Notes, TA Recorder, Invoice, Allotment or Control Block, and Need Manual Review. Outputs candidate MessageEvents and display metadata only; does not create TaskCards or write Opera/PMS. +--- + +# 预订部业务事件识别 + +## 1. 业务目的 + +用本 skill 读取已经整理好的当前邮件素材包,判断这封邮件里有哪些预订部业务事件。 + +它处理的不是一个固定任务类型,而是一封邮件可能带来的完整预订部动作和补充业务信息:新订、改单、取消、付款凭证、名单、改团号、Trace、TA Recorder、发票、控房/配额,以及无法安全处理时的人工复核。与具体预订对象相关的补充事实即使只是告知,也可以生成 Trace;意图清楚但现有任务类型无法承接的其他当前内容必须通过邮件级展示字段保留。 + +本 skill 只输出候选 `MessageEvent`、未覆盖当前意图展示信息、候选目标 key、抽取字段、证据和结构化人工复核原因。它不创建真实 TaskCard,不写 Opera/PMS,不确认 Payment,不更新 Reservation Type,不生成 Invoice 或 Receipt。 + +## 2. 输入素材 + +期望 Main Agent 提供: + +- `source_message` +- `body_current` +- 当前附件、图片、PDF、Excel、OCR、表格和文件链接摘要 +- 合法取得的 `body_thread_evidence` +- 历史查询摘要,如适用 +- 信息系统上下文摘要,如适用 +- Main Agent 已能安全预拆的事件边界 +- `unknowns` 中由 Main Agent 保留的未覆盖当前业务内容 +- 不可读证据和冲突点 + +`source_message.source_message_id` 必须来自上游且为非空值;不得猜测。Main Agent 应在正文、附件和历史处理前先保存 source message identity。 + +Main Agent 只有在 `candidate_events` 已匹配至少一个支持业务事件时才调用本 skill。已匹配支持事件但 current 证据不足、目标不清或 current/history 边界不清时,不要猜,输出 `Need Manual Review`。输入可理解但没有匹配支持事件时由 Main Agent 输出 `S10`;输入不足、无法判断是否匹配支持事件时由 Main Agent 输出 `S99`。 + +## 3. 处理流程 + +1. 读取 `references/00-output-contract.md`,确认输出结构。 +2. 读取 `references/01-current-history-boundary.md`,确认 current 与历史证据边界。 +3. 读取 `references/03-current-content-completeness.md`,确认当前内容没有被事件路由静默丢弃。 +4. 读取 `references/02-event-routing-map.md`,选择候选业务事件。 +5. 按事件类型读取 10-18 业务事件 references。 +6. 涉及供应商表格、控房配额、房型、Rate Code、Fix Charge、手工价格或 stay date parsing 时,读取 30-54 规则 references。 +7. 如果不能安全输出普通候选事件,读取 `references/90-manual-review.md` 并输出结构化人工复核。 + +## 4. Reference 分区 + +基础契约: + +- `00-output-contract.md` +- `01-current-history-boundary.md` +- `02-event-routing-map.md` +- `03-current-content-completeness.md` + +业务事件: + +- `10-new-booking.md` +- `11-update-booking.md` +- `12-cancel-booking.md` +- `13-voucher-payment.md` +- `14-rooming-list.md` +- `15-amend-group-code.md` +- `16-trace-notes.md` +- `17-ta-recorder-note.md` +- `18-invoice.md` + +供应商和业务对象场景: + +- `30-qbd-liantai-workflow.md` +- `31-allotment-control-block.md` + +共享规则: + +- `50-room-type-mapping.md` +- `51-rate-code.md` +- `52-fix-charge.md` +- `53-manual-rate-code.md` +- `54-stay-date-parsing.md` + +异常和人工复核: + +- `90-manual-review.md` + +## 5. 硬边界 + +- 只有当前邮件证据能触发新的业务事件。 +- 历史只能绑定目标、旧值或上下文,不能单独触发普通业务。 +- 一个事件只承载一个目标对象;同一目标可以同时有主事件和一个按目标合并后的 linked Trace。 +- 一个 QBD/LianTai 当前有效行一个事件。 +- 不得把多个 `group_code` 合并到一个事件。 +- Voucher 必须有当前图片、PDF 或文件证据。 +- Rooming List 必须能证明是名单,不得把 booking update 表当名单。 +- Extra bed 是 Trace / Guest Request,不是房量,不是房型,不决定 Rate Code。 +- Meeting、meal、arrival notice、room preference/setup、已确定的 payment information 或其他具体预订补充信息,即使只是告知,也按目标生成 Trace;普通补充信息不输出 `Note`。 +- 同一封邮件、同一目标对象的多条补充信息合并成一个 Trace;多个目标对象分别生成 Trace。 +- 主任务已经完整承接的核心参数不得重复生成 Trace。 +- `notify_departments` 不清时使用空数组,不得仅因此输出人工复核。 +- 需要酒店批准的价格、退款、账期、付款政策或合同条件询问不是 Trace,且不得静默忽略。 +- 当素材包同时包含支持事件和 `unknowns[].category=unhandled_current_business_content` 时,普通事件或人工复核照常输出,并把每个 unknown 一对一规范化到顶层 `unhandled_current_intents`。 +- `unhandled_current_intents` 只承载意图清楚但当前不支持的业务内容;它不是事件,不得创建任务,也不得与 `extraction_warnings` 混用。 +- 房型和 Rate Code 必须由 reference 唯一支持;不唯一就人工复核或下游硬校验。 +- 素材包已经匹配支持业务事件但业务判断不安全时输出 `Need Manual Review`;`S10` 和 `S99` 都属于 Main Agent 入口结果,不由本 skill 输出。 +- 本 skill 只判断候选业务事件,不替用户决定是否回复邮件或进行其他非预订沟通。 +- 不输出真实 TaskCard ID、最终 Case 裁决、执行状态、Payment 确认、Block Status 转换、Receipt 或 Opera/PMS 写入结果。 + +## 6. 输出 + +将以下结构化 JSON 参数对象作为调用结果直接返回给 Main Agent: + +```json +{ + "source_message": { + "source_message_id": "" + }, + "message_events": [], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] +} +``` + +该结果是内存中的参数对象,不是 JSON 字符串或文件产物。根对象必须直接使用上述结构,不得增加 `booking_data`、`file`、`filename`、`artifact`、`download_url` 或其他文件包装层。 + +不得创建、写入、上传、附加或返回 `.json` 结果文件,不得用结果文件名、文件路径、下载链接、artifact 或文件引用代替该对象,不得使用 Markdown 代码块包装最终返回值,也不得增加 `output_mode`、`filename` 等交付控制字段。 + +以上限制不影响输入附件处理。事件中的 `attachments` 和 `file_references` 可以继续保存 Excel、PDF、图片等输入证据,但不能替代最终 JSON 参数对象。 + +每个事件必须包含: + +- `event_type` +- `event_role` +- `current_or_history` +- `source_event_index` +- `case_keys` +- `relevant_message_excerpt` +- `attachments` 或 `file_references` +- `context_used` +- `extracted_fields` +- `manual_review`,如需要 + +## 7. 判断原则 + +能安全拆分就拆分,不能安全拆分就人工复核。 + +能输出普通候选事件的前提是:当前动作明确、目标绑定明确、必要证据可读、业务 reference 支持、系统上下文没有明显阻塞。 + +输出前必须执行内容完整性检查:每项有业务意义的当前内容已经进入普通事件、业务人工复核或 `unhandled_current_intents`;不得仅因原文仍可在源邮件中查看而省略未覆盖意图。 + +人工复核也是有效业务结果。必须写清原因、缺失字段、冲突点、需要人工查看的证据和已确认字段。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/00-output-contract.md b/docs/import/20260710/归档/skills/booking-desk-event/references/00-output-contract.md new file mode 100644 index 0000000..a3b7a32 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/00-output-contract.md @@ -0,0 +1,343 @@ +# 输出契约 + +## 用途 + +定义 `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` 或空数组。 +- 上游没有提供 `source_message_id` 时属于本业务契约之外的输入或基础设施错误,不能产生一个声称合规的 `S10` 或 `S99`。 + +## Main Agent Notification Outcome + +`S10` 和 `S99` 共用以下源邮件查看通知结构: + +```json +{ + "source_message": { + "source_message_id": "", + "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 +} +``` + +- `S10` 固定使用 `status=no_booking_action_detected`、`reason_code=no_booking_action_detected`、`manual_review=null`。为保持信息系统兼容,字段和值不变;其业务含义是输入可理解但未匹配当前 Agent 支持的业务事件,不表示用户无需查看、回复或处理源邮件。 +- `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` 只用于输入不足、无法完成支持范围分类。已经匹配支持业务事件但参数或目标不安全时,使用业务事件 `Need Manual Review`。 + +`S99.manual_review` 必须使用: + +```json +{ + "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": {} +} +``` + +## 业务事件顶层结构 + +```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 和已被事件完整承接的内容不得重复输出。 +- 清楚但不支持的业务意图使用本字段;已匹配支持事件但参数或目标不安全时使用 `Need Manual Review`;内容不可读或抽取失败时使用 `extraction_warnings` 或相应人工复核。 +- `extraction_warnings` 只承载解析、OCR、抽取和证据质量问题,不得用来承载未覆盖业务意图。 +- `source_message_id` 只保留在根 `source_message`,不得在 item 内重复。 + +详细覆盖顺序遵循 `03-current-content-completeness.md`。 + +## 支持业务事件类型 + +- `New Booking` +- `Update Booking / Amendment` +- `Cancel Booking` +- `Cancel Allotment` +- `Voucher Received` +- `Payment Evidence` +- `Rooming List` +- `Allotment Maintenance` +- `AMEND GROUP CODE` +- `Invoice Generation` +- `Invoice Received` +- `Payment Notice` +- `Trace` +- `Manual RateCode` +- `TA RECORDER` +- `Note` + +普通预订补充信息统一输出 `Trace`。`Note` 暂时仅为旧契约兼容保留,不作为当前普通补充信息的路由结果,除非后续任务卡映射另有明确规则。 + +## 业务人工复核结果 + +- `Need Manual Review` + +## 单个事件 + +```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 +} +``` + +## 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": {} + } +} +``` + +## 派生事件 + +派生或关联事件使用: + +```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": { + "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,不写外部系统。 + +## 禁止输出 + +不得输出真实 TaskCard ID、最终 Case 状态、Payment 确认、Block Status 自动转换、Receipt、Invoice 文件、Opera/PMS 写入结果。 + +不得生成或返回结果 JSON 文件、结果文件引用、结果下载链接或 artifact。该限制只针对最终处理结果,不禁止下载、读取和解析输入附件,也不禁止在事件中保留输入附件证据。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/01-current-history-boundary.md b/docs/import/20260710/归档/skills/booking-desk-event/references/01-current-history-boundary.md new file mode 100644 index 0000000..d9346e2 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/01-current-history-boundary.md @@ -0,0 +1,58 @@ +# 当前邮件与历史边界 + +## 用途 + +区分当前邮件动作和历史证据。这个边界优先于所有业务类型判断。 + +本 reference 适用于 Main Agent 已经匹配至少一个支持业务事件并形成素材包的场景。输入可理解但没有匹配支持事件时由 Main Agent 输出 `S10`;输入不足、无法判断是否匹配支持事件时输出 `S99`,均不调用业务 skill。 + +## 当前邮件可以触发业务 + +当前素材包括: + +- `body_current` 中的新请求,以及与具体预订对象相关的补充事实、安排、要求或备注。 +- 当前附件、inline image、PDF、Excel、OCR、表格、文件链接。 +- 当前邮件明确继续上文并要求处理,例如 `please proceed`、`see attached`、`please update as attached`。 + +只有这些素材可以触发新的业务事件。 + +## 历史只做证据 + +历史素材包括 quoted thread、forwarded old mail、`body_thread` 和历史查询结果。历史可以提供: + +- 目标 key:`group_code`、confirmation number、reservation number、客人姓名 + 日期。 +- amendment 的旧值。 +- parent group / allocation 上下文。 +- 当前 Trace / Guest Request 的最近目标。 +- 解释当前证据所需的供应商或酒店上下文。 + +使用历史时,在 `context_used` 写明用途。 + +## 禁止事项 + +- 不得从 history-only 动作创建普通业务事件或 `unhandled_current_intents`。 +- 不得为只有确认收到、感谢、裸 FYI 或其他没有具体预订业务信息的消息查询历史来制造业务事件。 +- 不得用旧历史值覆盖当前值。 +- 除非文件是当前邮件真实发送的附件或链接,不得把历史 voucher、名单或表格当作当前证据。 + +## 人工复核 + +以下情况输出 `Need Manual Review`: + +- current/history 边界不清。 +- 历史查询返回多个互不相关目标。 +- 当前动作清楚但无法唯一绑定目标。 +- 历史证据与当前目标冲突。 +- 当前请求只针对部分 child group,但具体目标不清。 + +## Trace 例外 + +当前邮件包含可触发 Trace 的具体预订补充信息,但目标只在最近历史中清楚出现时,可以使用历史绑定目标。当前信息可以是要求,也可以是单纯告知。必须记录: + +- `target_key_source` +- `body_thread_used_only_as_evidence=true` +- `requires_downstream_hard_validation=true` + +历史本身仍不能触发 Trace。 + +同样地,历史只能帮助当前未覆盖意图绑定 `case_keys`,不能把历史中的审批询问、投诉、付款政策或其他内容变成当前展示 item。当前没有对应原文或附件证据时,`unhandled_current_intents` 必须为空。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/02-event-routing-map.md b/docs/import/20260710/归档/skills/booking-desk-event/references/02-event-routing-map.md new file mode 100644 index 0000000..d7f5c69 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/02-event-routing-map.md @@ -0,0 +1,58 @@ +# 事件路由地图 + +## 用途 + +从当前邮件动作选择业务事件类型,并决定需要读取哪些业务 reference。 + +## 路由顺序 + +1. Main Agent 先判断当前输入是否足以识别邮件意图;不足时输出 `S99`,不调用本 skill。 +2. Main Agent 按 `03-current-content-completeness.md` 盘点全部当前业务内容,不得识别一个事件后停止。 +3. Main Agent 将每项当前内容与本文件的支持业务事件目录匹配;整封邮件没有匹配时输出 `S10`,不调用本 skill。 +4. `candidate_events` 至少包含一个支持业务事件时,才调用本 skill;同时存在的清楚但不受支持内容保留在 `unknowns`。 +5. 已匹配支持事件但事件类型候选冲突、目标不清或参数不安全时,输出业务级 `Need Manual Review`。 +6. 能安全拆分时,每个目标对象独立路由,并把未覆盖意图一对一输出到顶层 `unhandled_current_intents`。 + +只有 Thank you、裸 FYI、acknowledgement、Noted、Received 等没有具体预订业务信息的文字不匹配 Trace。FYI 或单纯告知只要包含与明确预订对象相关的具体补充信息,就路由为 `Trace`。一般咨询和当前不支持的业务请求仍不应伪装成 Trace;`please confirm booking details` 或请酒店核对并回复既有预订当前没有对应支持事件。 + +当一封邮件同时包含支持事件和当前不支持的业务请求时,不得因为支持事件已命中而忽略剩余请求,也不得把整封邮件降级为 S10。支持部分正常路由,未覆盖部分进入 `unhandled_current_intents`。 + +## 路由表 + +| 当前信号 | 事件类型 | 读取 | +| --- | --- | --- | +| 新建 FIT、Group Block、Allotment、Control Block | `New Booking` | `10-new-booking.md` | +| Parent-to-child allocation creation | child `New Booking` + 必须 linked parent `Cancel Booking` release/cancel candidate | `31-allotment-control-block.md` | +| 修改日期、晚数、房型、房量、人数、价格或其他主订单字段 | `Update Booking / Amendment` | `11-update-booking.md` | +| 整单取消、CXL、release/cancel block | `Cancel Booking` / `Cancel Allotment` | `12-cancel-booking.md` | +| 当前 voucher、payment slip、bank transfer image/PDF/file | `Voucher Received` / `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、Reservation Note、Guest Request、extra bed、Meeting、function/banquet、meal、transport、room preference/setup、arrival notice、payment information 或其他具体业务告知 | `Trace` | `16-trace-notes.md` | +| Rooming List 目标需要 TA Recorder | `TA RECORDER` | `17-ta-recorder-note.md` | +| Invoice 请求、收到 invoice、payment notice | invoice/payment 相关事件 | `18-invoice.md` | +| 单独手工价格或 Rate Code 维护 | `Manual RateCode` | `53-manual-rate-code.md` | + +## 冲突优先级 + +- 当前只有 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 按当前有效行拆。 +- Parent-to-child allocation 按 child group code 拆,并额外输出一个 parent linked release/cancel candidate。 +- Extra bed、Meeting、meal、arrival notice、guest request 或其他预订补充信息与主业务同现时,按目标拆 linked Trace。 +- 同一封邮件、同一目标对象的多条补充信息合并成一个 Trace;多个目标对象分别生成 Trace。 +- Rooming List 按目标 group 拆,并可派生 TA Recorder。 + +无法安全拆分时,输出 `Need Manual Review`,写明冲突和需要查看的证据。 + +不符合 Trace 但具有当前业务意义的内容必须以 `unknowns[].category=unhandled_current_business_content` 保留原文。同邮件存在至少一个支持事件时输出到顶层 `unhandled_current_intents`;整封邮件没有支持事件时由 Main Agent 输出 S10。不得静默忽略。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/03-current-content-completeness.md b/docs/import/20260710/归档/skills/booking-desk-event/references/03-current-content-completeness.md new file mode 100644 index 0000000..14c27d8 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/03-current-content-completeness.md @@ -0,0 +1,111 @@ +# 当前邮件内容完整覆盖 + +## 用途 + +确保当前邮件中每一项有业务意义的内容都有明确去向。不得因为已经匹配一个支持业务事件,就停止读取或静默丢弃同一邮件中的其他当前意图。 + +本规则是邮件级共享规则,优先于具体事件 reference。它不扩大任务卡能力,也不把当前不支持的内容伪装成 `Trace` 或 `Need Manual Review`。 + +## 当前内容盘点 + +盘点范围包括: + +- `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. 已匹配支持事件但参数、目标或证据不安全:业务级 `Need Manual Review`。 +3. 意图清楚但现有事件或任务卡不支持,且同邮件还有至少一个支持事件:最终 `unhandled_current_intents`。 +4. 意图清楚但整封邮件没有任何支持事件:Main Agent 输出 `S10`。 +5. 输入不足,无法判断是否匹配支持事件:Main Agent 输出 `S99`。 + +不得用 `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`、`Need Manual Review` 或 `S99`。 +- `extraction_warnings` 只承载解析、OCR、抽取和证据质量问题,不承载清楚但不受支持的业务意图。 + +## 示例 + +- 当前付款凭证 + “余款能否入住时支付”:输出 `Payment Evidence`,并输出一个未覆盖意图。 +- “余款将在入住时支付”且目标唯一:输出 `Trace.payment_information`,不输出未覆盖意图。 +- 当前只有清楚的付款政策询问,没有任何支持事件:Main Agent 输出 `S10`,不调用业务 skill。 +- 历史中有审批询问、当前只有 `Thanks`:不得从历史生成未覆盖意图。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/10-new-booking.md b/docs/import/20260710/归档/skills/booking-desk-event/references/10-new-booking.md new file mode 100644 index 0000000..d306e5e --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/10-new-booking.md @@ -0,0 +1,73 @@ +# 新订 + +## 适用业务 + +用于当前邮件要求创建新的: + +- FIT Reservation +- Group Block +- Allotment / Control Block +- QBD/LianTai 当前新订行 +- Parent-to-child allocation 的 child group + +## 判断 + +- 小于 5 间通常按 FIT。 +- 5 间及以上通常按 Group Block。 +- 明确 allotment、allocation、control block、控房、配额、取配、配合房时,按控房/配额对象优先。 + +## 不适用 + +- 修改既有订单。 +- 取消。 +- 只有 voucher 或 payment evidence。 +- 只有 Rooming List。 +- 旧 Group Code 改新 Group Code。 +- 只有 Trace 或 extra bed。 +- 动作只在历史邮件中。 + +## 必要证据 + +普通候选事件需要: + +- 当前创建动作。 +- 目标 key 或足够清楚的新对象身份。 +- 按 `54-stay-date-parsing.md` 可安全识别的入住/离店、房型房量、客人或团队信息等最小新订字段。 +- 系统上下文没有 existing valid record、pending/open task、lock 或 active workflow 阻塞。 +- 涉及房型、Rate Code 或 Fix Charge 时,有对应 reference 支持,或明确要求下游硬校验。 + +## 抽取字段 + +保留: + +- group code / confirmation number,如有 +- arrival / departure / nights +- date evidence:`hotel_date_raw`、`tour_date_raw`、`action_date_raw`、`sheet_month_year`、`date_inference_basis` +- guest or group name +- room items,保留 raw room type 和 mapped room code +- supplier / channel +- booking object type +- QBD/LianTai row evidence +- parent allocation context +- Rate Code / settlement price evidence +- Fix Charge evidence + +## Linked Trace + +New Booking 同事件出现 extra bed、Meeting、meal、arrival notice、room preference/setup、已确定的 payment information 或其他具体预订补充信息时,按目标额外生成一个 linked `Trace`。补充信息即使只是告知,也生成 Trace;主任务已经完整承接的入住日期、房型、房量等核心参数不得重复生成 Trace。 + +同一目标的多条补充信息合并到一个 Trace;多个目标分别生成 Trace。Extra bed 不计入房量,不影响 FIT/Group,不作为 PMS 房型,不决定 Rate Code。 + +## 人工复核 + +以下情况输出 `Need Manual Review`: + +- key identity 不清。 +- 已有订单、pending task、open task 或 active workflow。 +- 当前动作可能其实是 update、cancel 或 group-code change。 +- current/history boundary 不清。 +- 多对象无法拆分。 +- QBD/LianTai 当前行证据不可读。 +- room mapping、Rate Code 或 settlement price 不唯一。 +- Fix Charge 无法安全解析。 +- Parent-to-child allocation child line 不完整。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/11-update-booking.md b/docs/import/20260710/归档/skills/booking-desk-event/references/11-update-booking.md new file mode 100644 index 0000000..4a30f08 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/11-update-booking.md @@ -0,0 +1,88 @@ +# 修改预订 + +## 适用业务 + +用于当前邮件要求修改既有: + +- FIT Reservation +- Group Block +- Allotment / Control Block +- 日期、晚数、房型、房量、人数、价格、Rate Code、备注或其他订单细节 + +## 支持的修改 + +- `update_stay_dates` +- `update_nights` +- `update_room_type_or_quantity` +- `update_guest_count` +- `update_allotment_control_block` +- `manual_rate_code_or_settlement_price` +- `update_fix_charge` +- 与主修改同现的 guest request 线索 + +## 不适用 + +- Parent-to-child allocation creation,child creation 走 `New Booking`。 +- 旧 Group Code 改新 Group Code,走 `AMEND GROUP CODE`。 +- 只有 extra bed,走 `Trace`。 +- 只有 voucher/payment evidence。 +- 只有 Rooming List。 +- 动作只在历史邮件中。 +- 纯确认或信息消息不属于 Update;其中包含具体预订补充信息时按 `Trace` 处理。 + +## 必要证据 + +普通候选事件需要: + +- 当前 amendment action。 +- existing target key 或唯一目标绑定。 +- reliable before/after 或明确 change detail。 +- 涉及入住、离店或晚数时,按 `54-stay-date-parsing.md` 解析并保留日期证据。 +- 系统上下文支持继续处理。 +- 涉及房型、Rate Code、Fix Charge 时,reference 支持或下游硬校验明确。 + +## Before / After + +可得时保留: + +```json +{ + "before_after": [ + { + "field": "arrival_date", + "before": "", + "after": "", + "evidence": "" + } + ] +} +``` + +真正 amendment 无法建立 before/after 时,输出人工复核。 + +日期相关 before/after 必须保留 `hotel_date_raw`、`tour_date_raw`、`action_date_raw`、`sheet_month_year` 和 `date_inference_basis`,如适用。 + +## Linked Trace + +Extra bed、Meeting、meal、arrival notice、room preference/setup、已确定的 payment information 或其他具体预订补充信息与有效 update 同现时: + +- 输出主 `Update Booking / Amendment`。 +- 按目标输出一个 linked `Trace`;同一目标的多条补充信息合并。 +- 补充信息即使只是告知,也生成 Trace。 +- Update 已完整承接的 before/after 核心字段不得重复写成 Trace。 + +如果当前只有 Trace 补充信息,或只要求 add/update/cancel extra bed,不创建 Update 事件。 + +## Fix Charge + +Fix Charge 可通过 Update 维护,但必须确认目标和动作类型:add、update 或 cancel。不清楚时人工复核。 + +## 人工复核 + +- 原订单找不到或目标不清。 +- 前置任务未完成或系统上下文阻塞。 +- before/after 不可靠。 +- 当前动作可能是 New Booking、Cancel 或 Amend Group Code。 +- room mapping、Rate Code 或 price 不唯一。 +- Fix Charge 动作或金额不清。 +- QBD/LianTai row、highlight、strikethrough evidence 不可读。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/12-cancel-booking.md b/docs/import/20260710/归档/skills/booking-desk-event/references/12-cancel-booking.md new file mode 100644 index 0000000..a80f4d4 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/12-cancel-booking.md @@ -0,0 +1,58 @@ +# 取消预订 + +## 适用业务 + +用于当前邮件要求取消或释放: + +- FIT Reservation +- Group Block +- Allotment / Control Block +- 控房或配额 + +可接受信号: + +- cancel booking / reservation / group +- release or cancel block +- cancel allotment / allocation / control block +- `CXL` +- 明确整单取消 + +## 事件类型 + +- 普通订单取消:`Cancel Booking` +- 控房/配额取消:`Cancel Allotment` + +## 部分修改边界 + +减少房量、改日期、释放部分 allocation 可能是 Update,不是整单 Cancel。不清楚时输出人工复核。 + +## Parent Allocation Linked Event + +当当前证据已经确认 parent-to-child allocation creation,且 parent group code 清楚时,必须为 parent group 生成 linked cancel/release 候选。 + +这个事件: + +- 不替代 child New Booking。 +- 不要求字面 cancel / CXL。 +- 必须来自当前 parent-child split 证据。 +- 不得由历史证据单独触发。 +- 只是候选事件,不代表 PMS 已取消成功,不创建真实 TaskCard,不写外部系统。 + +## 不适用 + +- 新建,除 parent release linked event 外。 +- 普通 date/room/guest/price amendment。 +- Voucher/payment evidence。 +- Rooming List。 +- 只有 Trace。 +- cancel action 只在历史邮件中。 + +## 人工复核 + +- cancel target 不清。 +- 出现 `CXL` 但不确定是否当前动作。 +- 不清楚是整单取消还是部分 update。 +- parent release evidence 缺 parent/child 关系。 +- parent group code、child list 或 parent-child split evidence 不清。 +- 多目标无法拆分。 +- 系统上下文显示 conflict、lock 或 already completed state。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/13-voucher-payment.md b/docs/import/20260710/归档/skills/booking-desk-event/references/13-voucher-payment.md new file mode 100644 index 0000000..f72b827 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/13-voucher-payment.md @@ -0,0 +1,104 @@ +# Voucher 与付款凭证 + +## 适用业务 + +仅当当前邮件包含真实当前 voucher 或 payment evidence 时使用: + +- 当前附件图片 +- 当前 inline image +- 当前 PDF +- 当前 downloaded file reference + +事件类型: + +- `Voucher Received` +- `Payment Evidence` + +两者共享当前附件、目标绑定和安全规则,但不是同一个事件:实际 `CREDIT VOUCHER` 文档输出 `Voucher Received`;银行转账、现金存款或交易回执输出 `Payment Evidence`。Voucher 不证明资金到账,Payment Evidence 也不表示酒店已经确认付款。 + +## Credit Voucher + +典型信号: + +- LT / LianTai logo。 +- 泰國聯泰旅運集團有限公司 / `LIAN TAI TRAVEL GROUP (THAILAND) CO., LTD.` +- `CREDIT VOUCHER` +- `DATE`、`CODE / 團號`、`IN`、`OUT` +- `SGL`、`TWN`、`TRP` +- `B`、`L`、`D` +- 手写、盖章或签名 + +`CODE / 團號` 是优先目标来源。模糊、空白或冲突时人工复核。 + +## Bank Transfer Slip + +付款证据可包括: + +- 银行转账成功截图 +- 现金存款收据 +- 交易收据 +- payment slip +- payment confirmation 图片/PDF + +## 不得仅凭文本触发 + +以下信号不能单独触发: + +- 关键词 `voucher` / `payment slip` +- subject `FULL PAYMENT` +- 文字说 voucher 已发送 +- 只有附件文件名 +- 酒店回复 thanking voucher +- 只有历史中的 voucher +- 没有当前 image/PDF/file evidence 的表格状态 + +文本提到 voucher 但缺当前证据时:如果没有匹配任何支持业务事件,由 Main Agent 输出 `S10`;如果已明确匹配 Voucher/Payment 事件但当前证据不可读或目标不安全,输出业务级 `Need Manual Review`。 + +## 同邮件其他付款内容 + +- 当前付款凭证与需要酒店决定的付款安排询问同现时,付款文件输出 `Payment Evidence`,询问原文按 `03-current-content-completeness.md` 输出到 `unhandled_current_intents`。 +- 例如“余款能否入住时支付”是付款政策审批询问,不是 `Payment Notice`,也不得改名为 Trace。 +- “余款将在入住时支付”是已经确定、需要随预订保留的补充付款安排,可以按 `16-trace-notes.md` 输出 `Trace.payment_information`。 +- 当前凭证与未覆盖意图必须分别保留;不得因为付款图片已成功路由就丢弃正文中的审批询问。 + +## 目标绑定 + +一个 voucher/payment event 绑定一个目标 group code 或 reservation key。多个 group code 必须拆分。 + +一张 bank slip 覆盖多个 group code 时,每个 group code 输出一个事件,并在 `extracted_fields.related_group_codes` 保留完整集合。多个事件可以共享同一个 `voucher_attachment.file_reference`。 + +不要把 amount、date、bank account、payer、payee、reference number、QR 或 memo 抽成业务字段。用户应查看原始图片或 PDF。 + +## 下游意图 + +事件可以保留下游意图:付款确认后将 Reservation Type 更新为 `PD`。本 skill 不确认 payment,也不执行更新。 + +```json +{ + "voucher_attachment": { + "display_original": true, + "file_reference": "" + }, + "requires_department_routing": true, + "department_routing": [ + { + "department": "Finance", + "purpose": "confirm_payment_received", + "required": true, + "status": "pending" + } + ], + "post_confirmation_intent": { + "reservation_type": "PD", + "status": "pending_payment_confirmation" + } +} +``` + +## 人工复核 + +- 当前 image/PDF/file 不可读。 +- 多个 group code 无法拆分。 +- voucher subtype 不清。 +- target key 缺失且无法唯一绑定。 +- 证据只有历史或文件名。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/14-rooming-list.md b/docs/import/20260710/归档/skills/booking-desk-event/references/14-rooming-list.md new file mode 100644 index 0000000..0e6f503 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/14-rooming-list.md @@ -0,0 +1,58 @@ +# Rooming List 与 Name List + +## 适用业务 + +用于当前邮件提供: + +- Rooming List +- Name List / NAMELIST / NAME LIST +- guest list +- 分房名单 +- guest-level list material 的文件、链接或内容 + +载体可以是附件、inline file、Google Drive / 网盘链接、downloaded file reference、PDF、图片、Excel、OCR 或 table。载体本身不够,必须证明内容或上下文确实是名单。 + +## 正向证据 + +- 当前文本明确说 Rooming List / Name List / 分房名单 / 客人名单。 +- 当前文件内容有 guest-level rows、names 或 room assignment。 +- LianTai/QBD 名单附件的 file name 匹配完整 group code 格式,且与表内字段、正文或历史上下文至少一项互相印证。 +- 当前文件是在直接回复酒店最近索要 rooming list。 +- QBD/LianTai 当前事件明确是 NAME LIST / Rooming List,且名单证据可读。 + +## 负向证据 + +不要把以下内容当作 Rooming List: + +- QBD/LianTai booking update table。 +- `BOOKING 01-2026` 之类月度 booking sheet。 +- 包含 group code、pax、itinerary、hotel、status、UPDATE、NEW BOOKING、AMEND、CXL、CFM、room quantity、price、hotel response 的表。 +- invoice、payment、voucher、allotment、control block sheet。 +- 只有文件名,缺名单证据。 +- history-only rooming list mention。 + +## 目标拆分 + +- 按 target group/object 拆分。 +- workbook 可包含多个 group 或 sheet。 +- sheet name 是 evidence,不是最终证明。 +- file name 可以作为 target group binding evidence;不得仅凭 file name 单独决定 group code。 +- 一个目标对象一个 Rooming List event。 + +## 下游意图 + +Rooming List 可能派生: + +- `TA RECORDER` +- `Note` +- Routing / PM room / Block Status update intent + +本 skill 只输出候选事件和意图。真实导入和更新由信息系统执行。 + +## 人工复核 + +- file/link 无法下载或读取。 +- 不清楚文件是名单还是 booking update table。 +- target group binding 不清。 +- 多个 group 无法安全拆分。 +- sheet/file name 与表内字段、当前文本、历史上下文或系统上下文冲突。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/15-amend-group-code.md b/docs/import/20260710/归档/skills/booking-desk-event/references/15-amend-group-code.md new file mode 100644 index 0000000..a605ac6 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/15-amend-group-code.md @@ -0,0 +1,58 @@ +# 修改 Group Code + +## 适用业务 + +用于当前事件明确要求把既有 booking、group 或 reservation 的 Group Code 从旧值改为新值。 + +事件类型:`AMEND GROUP CODE` + +## 强信号 + +- `AMEND GROUP CODE TO : ` +- `AMEND GROUP CODE` +- `A to B` +- `A change to B` +- `A 改为 B` +- `A เปลี่ยนเป็น B` + +必须能表达 old group code -> new group code 的方向关系。 + +## 来源 + +可以来自当前 body、table、PDF、image OCR 或附件内容。 + +## 输出字段 + +前端展示字段保持窄口径: + +- `old_group_code` +- `new_group_code` + +其他证据放在 `context_used` 或 `extracted_fields`。 + +## 不适用 + +- 只有一个 group code,old/new 关系不清。 +- 普通 date/room/price/guest amendment。 +- New Booking creation。 +- Cancel / release / allotment。 +- Voucher/payment evidence。 +- Rooming List。 +- 纯确认或 history-only group code change。 +- 主语义是 cancel/release,只是顺带出现 moved/join group。 + +## 表格规则 + +不得依赖固定列、行、单元格或 sheet name 作为业务规则。必须依赖文本语义和 old/new 关系。单元格位置只能作为 evidence。 + +## Linked Trace + +AMEND GROUP CODE 同邮件出现 extra bed、Meeting、meal、arrival notice、room preference/setup、已确定的 payment information 或其他具体预订补充信息时,按目标额外生成一个 linked `Trace`。同一目标的多条补充信息合并;补充信息即使只是告知,也不得忽略。old/new group code 核心字段不得重复生成 Trace。 + +## 人工复核 + +- old code 缺失。 +- new code 缺失或不可读。 +- 方向不清。 +- 多个 possible old/new pair 冲突。 +- current/history boundary 不清。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/16-trace-notes.md b/docs/import/20260710/归档/skills/booking-desk-event/references/16-trace-notes.md new file mode 100644 index 0000000..100707e --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/16-trace-notes.md @@ -0,0 +1,211 @@ +# Trace 与预订补充信息 + +## 定义 + +`Trace` 用于保存当前邮件中与具体预订对象相关、但不属于主任务核心参数的补充业务信息。 + +补充信息可以是要求、安排、备注或单纯告知。只要当前内容包含具体预订业务信息并能绑定目标,就生成 `Trace`;不要判断发件人是否明确要求酒店记录、执行或转交。 + +主任务已经完整表达的核心参数不得重复生成 `Trace`。例如 New Booking 的入住日期、房型和房量仍属于 New Booking;同邮件中的 meeting、meal、arrival notice 或 room preference 才作为补充信息生成 `Trace`。 + +## 触发条件 + +普通 `Trace` 必须同时满足: + +- 信息来自当前邮件正文、当前附件、当前图片/OCR、当前表格或当前明确继续处理指令。 +- 信息包含具体预订业务内容,不是只有礼貌或收件确认文字。 +- 信息能唯一绑定 `group_code`、confirmation number、reservation number,或能关联同邮件中目标明确的主事件。 +- 信息不属于主任务已经完整承接的核心参数。 + +历史内容只能补充目标 key 或解释当前信息,不能单独触发 `Trace`。 + +事件类型固定为: + +- `Trace` + +普通预订补充信息不再输出 `Note`。`Note` 仅为旧契约兼容保留,除非后续任务卡映射另有明确规则。 + +## 子类型 + +- `extra_bed`:同一 Trace 的全部条目都是 extra bed。 +- `general_request`:包含任意非 extra-bed 条目,包括 mixed Trace。 + +## Trace 输出 + +`extracted_fields` 固定使用: + +```json +{ + "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": "FULL DAY | null", + "pax": 110, + "notify_departments": [] + } + ], + "notify_departments": [] +} +``` + +规则: + +- `trace_text` 必须完整保留所有补充信息原文,按当前证据中的出现顺序使用换行连接;结构化字段不能替代原文。 +- `trace_items` 每条补充信息一个 item,顺序与 `trace_text` 一致。 +- `category` 只能使用上述枚举;没有更具体类别时使用 `general_information`。 +- `service_date`、`service_period_raw` 和 `pax` 只在当前证据或合法日期上下文可以唯一支持时填写,否则为 `null`。 +- item 的 `notify_departments` 只保留当前证据明确指定或本规则可以唯一确定的部门;无法确定时使用空数组。 +- 事件级 `notify_departments` 是所有 item 已知部门的去重合集;全部未知时使用空数组。 +- `notify_departments` 为空不阻塞 Trace,不得仅因此输出人工复核。 + +## General Request + +`general_request` 包括但不限于: + +- Meeting、conference、function、banquet。 +- Meal arrangement。 +- Airport transfer 或其他 transport arrangement。 +- Non-smoking、high floor、away from elevator、same floor 等 room preference。 +- Honeymoon、房间布置、amenity placement 等 room setup。 +- Guide arrival、到店安排、接待信息或其他具体预订补充事实。 +- 已确定的补充付款安排,例如“剩余款项将在入住时支付”。 +- 其他能绑定具体预订对象、且不属于主任务核心参数的补充业务信息。 + +以上是开放示例,不是封闭白名单。即使当前内容只是告知,没有出现 `please note`、`please arrange`、`please inform` 等动作词,也按本规则生成 Trace。 + +`payment_information` 只表示已经确定、需要随预订保留的补充付款安排。它不包括付款凭证、到账结果、Payment Notice、Invoice、催款、价格或付款条件审批询问;这些内容继续按各自业务规则路由,不得为了生成 Trace 任务卡而改名。 + +例如 New Booking `HD260510A` 同邮件出现 `12/5 FULL DAY Meeting 110 PAX` 时,额外生成 linked `Trace.general_request`,并保留: + +```json +{ + "trace_subtype": "general_request", + "trace_text": "12/5 FULL DAY Meeting 110 PAX", + "trace_items": [ + { + "category": "meeting", + "text_raw": "12/5 FULL DAY Meeting 110 PAX", + "service_date": "2026-05-12", + "service_period_raw": "FULL DAY", + "pax": 110, + "notify_departments": [] + } + ], + "notify_departments": [] +} +``` + +## Extra Bed + +Extra bed item 使用通用 item 结构: + +```json +{ + "category": "extra_bed", + "text_raw": "", + "service_date": null, + "service_period_raw": null, + "pax": null, + "notify_departments": ["FO", "HSK"] +} +``` + +同时继续在事件级 `extracted_fields` 保留原有业务意图字段,不得移动或删除: + +```json +{ + "occupancy_update": "3adult", + "requires_rate_update": true, + "rate_adjustment_formula": "rate_code_price / 2 * 3" +} +``` + +只要合并后的 Trace 包含 extra bed item,就保留上述事件级字段;全部 item 都是 extra bed 时使用 `trace_subtype=extra_bed`,否则使用 `general_request`。 + +硬边界: + +- 不计算最终价格。 +- 不决定 Rate Code。 +- 不把 extra bed 当 room quantity。 +- 不把 `U-เตียงเสริม` 当 PMS room type。 +- 原始 extra bed price text 只能作为 evidence。 + +## 部门规则 + +现有明确映射继续使用: + +- Non-smoking、high floor、away from elevator、same floor:`FO`。 +- Set Honeymoon、房间布置、amenity placement、需要 housekeeping 准备的要求:`FO` + `HSK`。 +- Extra bed:`FO` + `HSK`。 + +Meeting、function、meal、transport、payment information 或其他类别没有明确部门映射时使用空数组,交由任务卡用户确认,不输出人工复核。 + +## 告知、礼貌文字与审批询问边界 + +以下当前内容生成 Trace: + +- `FYI guide will arrive at 20:00` 等带有具体预订事实的告知。 +- 已确定的安排或事实,即使没有要求酒店采取动作。 + +以下内容不生成 Trace: + +- 只有 `Thanks`、`Noted`、`Received`、`FYI`、`confirmed receipt` 或同类礼貌/收件确认,没有任何具体预订业务信息。 +- Booking Confirmation Request、`please confirm booking details` 或要求酒店核对并回复既有预订。 +- 需要酒店批准或决定的价格谈判、退款、减免、豁免、账期、付款政策或合同条件询问。 +- 例如“剩余款项可以入住时支付吗”属于付款政策审批询问,不是 Trace;“剩余款项将在入住时支付”属于已确定的 payment information,可以是 Trace。 + +不符合 Trace 但具有当前业务意义的内容不得静默忽略。必须在 Main Agent 素材包的 `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": [] +} +``` + +同邮件存在至少一个支持事件时,按 `03-current-content-completeness.md` 输出到业务根对象的 `unhandled_current_intents`;整封邮件没有支持事件时由 Main Agent 输出 S10。未覆盖意图不是 Trace,也不创建任务。 + +## 独立、关联与合并 + +- 同一封邮件、同一目标对象只生成一个 `Trace`。 +- 同一目标的多条补充信息合并进一个 `trace_text` 和多个 `trace_items`。 +- 多个 `group_code` 必须分别生成 Trace,不得合并到数组型 `case_keys.group_code`。 +- 独立 Trace 必须由当前证据或允许的历史证据唯一绑定目标。 +- Trace 与 New Booking、Update Booking / Amendment 或 AMEND GROUP CODE 同现时,输出主事件和单独 linked Trace。 +- linked Trace 保留 `related_source_event_index`、`related_event_type`、`relationship_type=linked_trace` 和 `requires_downstream_hard_validation=true`。 +- 不得把 Trace 吞进主事件的普通备注字段。 + +## Cancel 边界 + +Cancel 行不会因为被取消对象历史上有补充信息就自动生成 Trace。只有当前邮件同时提供新的、需要保留的具体预订补充信息时才生成 Trace。 + +## 人工复核 + +只有以下情况输出 `Need Manual Review`: + +- 当前 Trace 内容不可读,无法保留可靠原文。 +- 当前信息无法唯一绑定目标对象。 +- 历史目标候选冲突。 +- 同邮件存在多个主事件,补充信息无法判断属于哪个目标。 +- current/history 边界不清。 +- extra bed rate update 无法安全附着到目标。 + +以下情况本身不构成人工复核: + +- `notify_departments` 不清。 +- `service_date`、`service_period_raw` 或 `pax` 缺失。 +- 当前信息是要求还是单纯告知不清。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/17-ta-recorder-note.md b/docs/import/20260710/归档/skills/booking-desk-event/references/17-ta-recorder-note.md new file mode 100644 index 0000000..60005fa --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/17-ta-recorder-note.md @@ -0,0 +1,66 @@ +# TA Recorder 与 Note + +## 适用业务 + +TA Recorder 通常由已确认的 Rooming List / Name List 事件派生。 + +事件类型: + +- `TA RECORDER` +- `Note` + +## Direct Request + +只有同时满足以下条件时,才允许直接从当前邮件识别 TA Recorder: + +- 当前文本明确要求 maintain/update TA Recorder。 +- 目标 `group_code` 唯一。 +- 存在名单或分房附件、链接或 file reference。 +- 文件与目标绑定清楚。 + +## 派生规则 + +Rooming List 已确认 target group codes 时: + +- 每个目标 `group_code` 派生一个 TA Recorder event。 +- 多个目标可以共享同一个 workbook/file reference。 +- 保留与 Rooming List event 的关系。 + +TA Recorder 不是一封邮件一张卡、一个 workbook 一张卡或一个 sheet 一张卡。它是一个目标 `group_code` 一个事件。 + +## Sheet 规则 + +- Sheet name 可以作为 binding evidence。 +- Sheet name 不得单独决定 group code。 +- File name 可以作为 binding evidence;当 file name 匹配完整 group code 格式时,仍需 Rooming List 目标绑定已确认后才可派生 TA Recorder。 +- Sheet name 或 file name 与表内字段、正文、历史上下文冲突时,输出人工复核。 +- `IN9-17` 不是 group code。 +- `总名单` 是 shared evidence,不是单独目标。 +- `WpsReserved_CellImgList` 等系统 sheet 忽略。 + +## Note + +Rooming List 也可能派生 Note。Note 字段可包含: + +- payer code +- charge code +- breakfast +- note text + +具体可写字段由信息系统模板和人工确认决定。 + +## 不适用 + +- `TA` 只是 travel agent、source 或 reservation type。 +- parent Rooming List 是人工复核。 +- 文件不可读或无权限。 +- 没有名单/分房文件。 +- current/history boundary 不清。 + +## 人工复核 + +- target group code 不清。 +- attachment/file binding 不清。 +- workbook 包含多个 group,但 target split 未确认。 +- sheet/file name 与正文、表内字段或 Rooming List binding 冲突。 +- parent Rooming List 未确认。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/18-invoice.md b/docs/import/20260710/归档/skills/booking-desk-event/references/18-invoice.md new file mode 100644 index 0000000..ac2011d --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/18-invoice.md @@ -0,0 +1,32 @@ +# Invoice 与 Payment Notice + +## 适用业务 + +用于当前证据明确要求: + +- 生成 Proforma Invoice。 +- 提供 invoice 所需材料。 +- 收到已有 invoice 或 revised invoice。 +- 发送不属于 voucher/payment image evidence 的 payment notice。 + +事件类型: + +- `Invoice Generation` +- `Invoice Received` +- `Payment Notice` + +`Payment Notice` 是清楚的付款通知,不是付款条件审批。需要酒店决定是否接受分期、延期、账期、余款到店支付或其他付款政策的询问,不输出 `Payment Notice`:同邮件存在其他支持事件时进入 `unhandled_current_intents`,整封邮件没有支持事件时由 Main Agent 输出 S10。 + +当前邮件同时包含 bank slip/payment receipt 与付款安排询问时,文件按 `13-voucher-payment.md` 输出 `Payment Evidence`,询问按 `03-current-content-completeness.md` 单独保留;不得合并后遗漏询问。 + +## 安全边界 + +- 不生成 Invoice Excel/PDF。 +- 不确认 payment。 +- 不创建 receipt。 +- 不执行 storage 或 accounting 动作。 +- 只输出候选事件、证据、目标 key 和人工复核点。 + +## 人工复核 + +在详细 invoice 规则未完善前,除非 event type 和目标都非常明确,否则优先 `Need Manual Review`。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/30-qbd-liantai-workflow.md b/docs/import/20260710/归档/skills/booking-desk-event/references/30-qbd-liantai-workflow.md new file mode 100644 index 0000000..9a1a370 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/30-qbd-liantai-workflow.md @@ -0,0 +1,94 @@ +# QBD / LianTai 表格工作流 + +## 适用场景 + +当当前邮件、附件、发件人、主题、表格、OCR 或抽取证据显示 QBD/LianTai 工作流时读取。 + +常见信号: + +- QBD / LianTai 发件人或渠道。 +- Excel/table 附件。 +- `BOOKING UPDATE`、`NEW BOOKING`、`AMEND`、`AMD`、`AMD ALLOTMENT`、`CXL`、`CFM`。 +- 黄色/高亮行、红字、删除线、hotel status column、泰文 `โรงแรม` 或酒店回应/状态单元格。 + +## 当前行规则 + +- 只有当前有效行可以触发事件。 +- 当前邮件附件中业务列被 yellow/highlight 的行全部视为当前有效行,不要求 `action_date_raw` 匹配邮件标题日期。 +- 业务列包括 group code、人数、行程、酒店、备注、酒店状态等列;只有序号列、标题、说明区或装饰单元格上色,不单独触发事件。 +- 多个当前有效行必须一行一个事件。 +- 不得用汇总字段或数组型 `case_keys.group_code` 合并多行。 +- 行证据、highlight、sheet、status 或 current-row selection 不可读时,输出人工复核。 +- 某一行不安全,不得阻塞其他安全行。 + +## 行证据 + +保留: + +- attachment/file name +- workbook/sheet +- row index / row label +- highlight / yellow / red text / strikethrough +- action/status cell raw text,包括 `action_date_raw` +- hotel date raw text、tour date raw text、sheet month/year、raw nights +- raw room type / room quantity +- raw price / rate marker +- hotel status text +- before/after evidence +- OCR/table confidence,如有 + +## 日期识别 + +酒店入住、离店和晚数必须按 `54-stay-date-parsing.md` 解析。 + +- 酒店列开头住期范围优先,例如 `14-15`、`15-17`、`31-02`。 +- 行程日期列用于补全年/月和校验,不得直接覆盖酒店住期。 +- `action_date_raw` 只作为动作证据,不是入住日期,也不是标黄行过滤条件。 + +## 新订行 + +当前行明确 `NEW BOOKING`、`NEW`、新增、จองใหม่,且目标和最小字段清楚时,可路由 `New Booking`。新订创建前通常没有 confirmation number;缺 confirmation number 不是复核原因。 + +## 修改行 + +Amendment 行需要可靠 current amendment 和 before/after evidence,除非实质是 parent-to-child allocation creation。 + +标准 OP 联系兜底文字不应当作业务 amendment,除非包含具体可执行请求。 + +## 取消行 + +`CXL`、cancel、release、cancel block 可路由 `Cancel Booking` / `Cancel Allotment`,前提是动作来自当前有效行且目标清楚。 + +## Parent-To-Child Allocation + +当当前证据显示 parent group code 后接 `AMEND TO`、`AMED TO`、allocation、allotment、control block、配额、取配、配合房等语义,并列出多个 child group code 及日期、晚数、房型、房量: + +- child line 路由为 `New Booking` / Allotment-Control Block creation。 +- 一个 child group code 一个事件。 +- parent 放入 `parent_group_code` 或 `parent_allocation_context`。 +- 不得把 parent group 写成 child event 的 `case_keys.group_code`。 +- 不要求普通 amendment 的 before/after。 +- parent group code 清楚时,必须额外生成独立的 linked parent release/cancel candidate,事件类型为 `Cancel Booking`。 +- parent release/cancel candidate 是候选事件,不代表 PMS 已取消成功。 + +如果某 child line 只有 generic `SUITE` 且无明确床型,保留 raw room type 并标记 downstream hard validation;不要让单个 child 阻塞整封邮件。 + +## 行内 Trace 补充信息 + +extra bed / 加床 / `เตียงเสริม` / `U-เตียงเสริม`: + +- 不计入 room quantity。 +- 不决定 FIT/Group。 +- 不映射 PMS room type。 +- 不决定 Rate Code。 +- 与主事件同现时,生成 linked `Trace`。 + +当前有效行或同邮件中与该行目标明确关联的 Meeting、meal、arrival notice、room preference/setup、已确定的 payment information 或其他具体预订补充信息,也生成 linked `Trace`。信息即使只是告知也保留;同一目标的多条补充信息合并为一个 Trace,多个 group code 分别生成 Trace。主事件已完整承接的核心字段不得重复写入 Trace。 + +## 价格 Marker + +`U-` 是 price/rate marker,不是 confirmation、reservation 或 booking reference。可用于 Rate Code / settlement price 判断。 + +## 名单边界 + +Rooming List / Name List 文件不得与 QBD/LianTai booking update 表混淆。看文件内容,而不是只看 Excel 载体。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/31-allotment-control-block.md b/docs/import/20260710/归档/skills/booking-desk-event/references/31-allotment-control-block.md new file mode 100644 index 0000000..32060be --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/31-allotment-control-block.md @@ -0,0 +1,56 @@ +# Allotment / Control Block + +## 适用业务 + +覆盖 allocation / allotment / control block 对象的新建、维护、释放、取消,以及 parent-to-child allocation creation。 + +## 新建控房/配额 + +当当前证据要求以下对象时,路由到 `New Booking`: + +- allotment +- allocation +- control block +- 控房 +- 配额 +- 取配 +- 配合房 +- parent group 拆分成 child group codes 并给出房量分配 + +对象类型记录: + +```json +{ + "booking_object_type": "Allotment / Control Block" +} +``` + +## Parent-To-Child Split + +当当前证据显示 parent group -> child group codes: + +- 每个 child group 一个 `New Booking`。 +- parent group 只是 child creation context。 +- 保留 `allocation_split_from_parent=true`。 +- 保留 parent original room summary,如有。 +- 不得因为文本写 `AMEND` 就归为普通改单。 +- parent group code 清楚时,必须额外输出独立 linked parent release/cancel candidate。 + +## Parent Release / Cancel + +当 current evidence 确认 child allocation creation,且 parent group code 清楚时,必须生成 linked parent release/cancel candidate,事件类型归 `Cancel Booking`。 + +该事件: + +- 不替代 child creation。 +- 不强制要求字面 `cancel` / `CXL`。 +- 不得从 history-only parent-child evidence 触发。 +- 只是候选事件,不代表 PMS 已取消成功,不创建真实 TaskCard,不写外部系统。 + +如果 parent group code、child group list 或 parent-child split evidence 不清,输出 `Need Manual Review`,reason code 使用 `parent_child_split_evidence_unclear`。 + +## 默认 Rate / Room + +Allotment / Control Block 缺 explicit Rate Code 时,只能在 room mapping 唯一且上下文明显符合 control-block default rules 时使用 `51-rate-code.md` 默认。 + +不唯一时保留 raw values,并输出人工复核或 downstream hard validation。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/50-room-type-mapping.md b/docs/import/20260710/归档/skills/booking-desk-event/references/50-room-type-mapping.md new file mode 100644 index 0000000..028d041 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/50-room-type-mapping.md @@ -0,0 +1,38 @@ +# 房型映射 + +## 安全立场 + +房型映射必须由本 reference 支持且唯一。未覆盖或存在歧义时,保留 raw text,输出人工复核或 downstream validation。不要凭常识猜 PMS room type。 + +## 已知规则 + +| Raw / normalized signal | PMS room type code | 说明 | +| --- | --- | --- | +| 普通 `DBL`, `Double`, `1 DBL` | `RM2` | 普通 DBL。 | +| 普通 `TWN`, `Twin`, `1 TWN` | `RM3` | 普通 TWN。 | +| `U-DBL`, `Upgrade DBL` | `RM2` | Rate Code 走 upgrade 口径。 | +| `U-TWN`, `Upgrade TWN` | `RM3` | Rate Code 走 upgrade 口径。 | +| `U-TRP`, `TRP`, `Triple` | `RM4` | 需结合人数/家庭房语境确认。 | +| `DBL SUITE`, `Double Suite`, `SUITE DBL` | `SU1` | 必须有明确 DBL suite 线索。 | +| `TWN SUITE`, `Twin Suite`, `SUITE TWN` | `SU6` | 必须有明确 TWN suite 线索。 | +| `FAMILY 3PAX`, `FAMILY 4PAX` | `SU3` | 家庭房规则。 | +| `ST+K`, `2卧1厅`, `FAMILY 1` 且组合证据匹配 | `SU3` | Rate Code 继续按渠道/价格判断。 | +| `HNM`, Honeymoon | 默认 DBL 路径 | 仅在没有更明确房型时使用。 | + +## 保留规则 + +- 始终保留 `room_type_raw`。 +- normalized room text 与 PMS code 分开保存。 +- 不得让 PMS code 抹掉 `U-`、`Sup`、高级房、suite/family、price 等原始线索。 +- 不得仅凭 room code 反推 Rate Code。 +- Extra bed / 加床 / `เตียงเสริม` 不是房型。 +- `Q10` 类 suite 文本必须结合 twin/double/layout evidence;不得只凭 `Q10` 单独映射。 + +## 人工复核 + +- generic `SUITE` 没有精确床型,且下游系统要求最终 PMS code。 +- `FAMILY 1` 缺 ST+K / 2卧1厅 / 1200 组合证据。 +- `HNM` 与更明确房型冲突。 +- 存在多个映射候选。 +- raw room text 混合不兼容类别。 + diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/51-rate-code.md b/docs/import/20260710/归档/skills/booking-desk-event/references/51-rate-code.md new file mode 100644 index 0000000..ddd4032 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/51-rate-code.md @@ -0,0 +1,77 @@ +# Rate Code 与结算价格 + +## 安全立场 + +Rate Code 必须由当前证据和本 reference 唯一支持。不要只凭 room code 猜。若 channel、market、room text、UP marker、price、breakfast、restaurant 或 supplier 上下文不足,输出人工复核。 + +## 已知规则 + +| 场景 | Typical Rate Code | +| --- | --- | +| QBD 普通 DBL/TWN 且无 `U` 或 Upgrade marker | `GRPA1-900` | +| QBD `U-DBL` / `U-TWN` / `U-TRP` 或 Upgrade marker | `GRPA2-850UP` 或对应 UP rate code | +| LianTai 普通 DBL/TWN 或 Sup DBL/Sup TWN | `GRP1-900` | +| LianTai `U-DBL` / `U-TWN` | `WHO1-850UP` | +| LianTai `ST+K` / `2卧1厅` / `FAMILY 1` 且价格 1200 | `WHO3-1200` | + +价格示例: + +- 850 -> `WHO1-850UP` +- 900 -> `GRP1-900` +- 1000 -> `WHO2-1000` +- 1200 -> `WHO3-1200` + +渠道和上下文优先于单纯价格。 + +## `U-` + +例如 `U-1200`: + +- 数字部分视为 settlement price / price marker。 +- 保留 `rate_code_raw = "U-1200"`。 +- 数字命中已知规则时输出命中。 +- 数字不可读或未覆盖时人工复核。 +- 绝不写入 confirmation/reservation/booking reference 字段。 + +## 控房/配额默认 + +示例: + +- `U-DBL` -> room `RM2`,settlement price 850。 +- `U-TWN` -> room `RM3`,settlement price 850。 +- `U-TRP` / `FAM` -> room `RM4`,settlement price 1275。 + +仅当事件明确是 new Allotment / Control Block、房型唯一映射,且当前没有不同明确规则时使用。 + +## 手工价格 + +当需要人工维护 settlement price: + +```json +{ + "rate_code": null, + "settlement_price": null, + "manual_settlement_price_required": true, + "manual_price_reason_code": "", + "price_evidence": [] +} +``` + +手工价格仍要求目标、日期/room items、price text 和系统上下文安全。 + +## 复合价格 + +对于 `2000THB+500`、`1800+500`、`2000THB*2 +500*2`: + +- 只用可分离的 base room price 判断 Rate Code / settlement price。 +- additional component 交给 `52-fix-charge.md`。 +- 不得把组件相加后当单一房价。 + +## 人工复核 + +- channel/market 未知或冲突。 +- raw room type 无法唯一映射。 +- UP marker 与普通 rate path 冲突。 +- price marker 不可读。 +- price 未命中任何规则。 + diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/52-fix-charge.md b/docs/import/20260710/归档/skills/booking-desk-event/references/52-fix-charge.md new file mode 100644 index 0000000..038c23e --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/52-fix-charge.md @@ -0,0 +1,68 @@ +# Fix Charge + +## 适用业务 + +Fix Charge 是 New Booking 或 Update Booking 的附加收费维护事项。它不是独立业务事件。 + +## 触发 + +只有当前证据包含可分离的复合单价或附加费组件时才识别,例如: + +- `2000THB+500` +- `1800THB+500` +- `2000THB*2` 后接 `+附加500*2` + +不得仅因普通房型或普通价格存在就触发。 + +## 解析 + +- `+` 前组件用于 Rate Code / settlement price。 +- `+` 后组件是 additional fee candidate。 +- 不得把 `2000+500` 合成 `2500` 判断 Rate Code。 +- 不得自行计算 quantity 或 total amount。 + +## 输出字段 + +```json +{ + "fix_charge_required": true, + "fix_charge_items": [ + { + "charge_type": "fixed_charge", + "amount": null, + "currency": null, + "pricing_mode": "unit", + "unit_basis": null, + "quantity": null, + "total_amount": null, + "raw_text": "", + "evidence_source": "", + "requires_followup_tool": true, + "followup_tool_name": "create_or_update_fix_charge / TBD" + } + ], + "additional_operations": [ + { + "operation_type": "create_or_update_fix_charge", + "status": "pending_target_id" + } + ] +} +``` + +## New Booking + +New Booking 的 Fix Charge 操作必须等 reservation/group/block 目标存在后再执行。 + +## Update Booking + +Update Booking 场景必须确认 current action 是 add、update 还是 cancel Fix Charge。目标或动作不清时人工复核。 + +## 人工复核 + +- 无法分离 base room price 与 additional fee。 +- additional fee amount 不可读。 +- 多个价格组件无法归属。 +- Update action 未说明 add/update/cancel。 +- 目标订单不清或系统上下文阻塞。 + diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/53-manual-rate-code.md b/docs/import/20260710/归档/skills/booking-desk-event/references/53-manual-rate-code.md new file mode 100644 index 0000000..5afd1f3 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/53-manual-rate-code.md @@ -0,0 +1,17 @@ +# Manual RateCode + +## 适用业务 + +只有当前证据明确请求或暗示 manual Rate Code / settlement price maintenance,且不应作为 New Booking 或 Update Booking 内嵌字段表达时,才使用 `Manual RateCode`。 + +## 安全规则 + +- 不得猜 Rate Code。 +- 不得仅凭 PMS room code 反推 Rate Code。 +- 保留 raw price、channel、market、room text、breakfast/meal/restaurant、supplier 和当前证据。 +- 目标对象不清时输出人工复核。 + +## 与 New/Update 的关系 + +如果 manual price 是 New Booking 或 Update 的一部分,应保留在该事件的 `extracted_fields.rate_code_result`,除非当前请求只有 manual rate maintenance。 + diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/54-stay-date-parsing.md b/docs/import/20260710/归档/skills/booking-desk-event/references/54-stay-date-parsing.md new file mode 100644 index 0000000..85d3cf8 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/54-stay-date-parsing.md @@ -0,0 +1,49 @@ +# Stay Date Parsing + +## 用途 + +统一解析 QBD/LianTai 表格里的酒店入住、离店和晚数。适用于当前邮件附件、表格、OCR 或抽取证据中出现的酒店日期、行程日期和动作日期。 + +## 字段 + +保留以下日期证据: + +- `hotel_date_raw`:酒店列开头的住期范围,例如 `14-15`、`15-17`、`31-02`。 +- `tour_date_raw`:行程日期列原文,例如 `2026/05/11\n2026/05/16`。 +- `action_date_raw`:备注或状态中的动作日期,例如 `11/05 AMD`、`12/05 NEW BOOKING`。 +- `sheet_month_year`:workbook sheet 名中的月份年份,例如 `BOOKING 05-2026`。 +- `arrival_date` +- `departure_date` +- `nights` +- `date_inference_basis` + +## 识别优先级 + +1. 优先使用酒店列开头的酒店住期范围作为 `arrival_date` / `departure_date`。 +2. 酒店列内有完整日期时,使用该完整日期范围。 +3. 行程日期列用于补全年/月和校验,不得直接覆盖酒店住期。 +4. sheet 名用于补全年/月;当行程日期可读时,优先用行程日期选择能落在行程范围内的酒店住期。 +5. 备注中的动作日期只作为 `action_date_raw`,不是入住日期,也不是标黄行过滤条件。 + +## 年月补全 + +- `15-17` 在 `BOOKING 05-2026` 且行程日期落在 2026 年 5 月时,解析为 `2026-05-15` 到 `2026-05-17`。 +- `31-02` 这类跨月范围必须按跨月处理;如果行程日期或 sheet 名无法唯一确定跨到哪个月,输出人工复核。 +- 当酒店日期范围缺年/月时,用行程日期补全;行程日期缺失时,用 sheet 名补全。 +- 如果补全后酒店住期不在行程日期范围内,保留 raw evidence 并输出人工复核。 + +## 晚数 + +- `15-17` 表示入住 15 日、离店 17 日,`nights=2`。 +- `14-15` 表示入住 14 日、离店 15 日,`nights=1`。 +- `nights = departure_date - arrival_date`。 +- 表格里的 `5N6D`、`5N7D` 是行程晚数线索,不得覆盖酒店住期晚数。 + +## 人工复核 + +以下情况输出 `Need Manual Review`,reason code 使用 `stay_date_inference_unclear`: + +- 酒店日期范围不可读。 +- 酒店日期、行程日期和 sheet 名无法唯一补全年/月。 +- 酒店住期与行程日期明显冲突且无法解释。 +- 跨月、跨年或格式异常导致 arrival / departure 无法安全确定。 diff --git a/docs/import/20260710/归档/skills/booking-desk-event/references/90-manual-review.md b/docs/import/20260710/归档/skills/booking-desk-event/references/90-manual-review.md new file mode 100644 index 0000000..2771c45 --- /dev/null +++ b/docs/import/20260710/归档/skills/booking-desk-event/references/90-manual-review.md @@ -0,0 +1,67 @@ +# 人工复核 + +## 入口结果边界 + +Main Agent 在调用业务 skill 前先完成支持范围分类: + +- 当前输入可理解,但没有匹配 `00-output-contract.md` 列出的支持业务事件时,输出 `S10`。 +- 当前输入不足,无法判断是否匹配支持业务事件时,输出 `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。意图清楚但不受支持不等于业务判断不安全,不得仅因此输出 `Need Manual Review`。 + +## Need Manual Review + +Main Agent 已经匹配至少一个支持业务事件,但普通事件处理不安全时,使用 `Need Manual Review`: + +- current action 在多个支持事件类型之间冲突且无法安全裁决 +- target object unclear 或不唯一 +- current/history boundary unclear +- 已匹配支持事件,但 attachment/file/OCR/table 不可读 +- required room type / Rate Code / settlement price mapping 无法唯一支持 +- QBD/LianTai row/highlight/current-row evidence 缺失或歧义 +- system context 显示 duplicate、pending/open task、active workflow、lock 或 conflict + +例如正文明确 `please update as attached`,但附件不可读时,已经匹配 Update 事件,应输出业务级 `Need Manual Review`,不得降级为 `S99`。 + +`Need Manual Review` 使用 `manual_review.review_record_type=business_event_review`。入口 `S99` 继续使用 `manual_review.review_record_type=main_agent_entry_review`,两者不得混用。 + +## 必要结构 + +业务级 Manual Review 必须包含: + +- `reason_code` +- `visible_reason` +- `review_record_type` +- `missing_fields` +- `blocking_points` +- `conflicting_points` +- `suggested_human_actions` +- `evidence_to_check` +- `known_fields` + +未知字段用 `null`、空字符串或空数组。不要猜。 + +## 常见 Reason Code + +- `current_history_boundary_unclear` +- `target_object_unclear` +- `multiple_target_candidates` +- `attachment_or_ocr_unreadable` +- `event_type_conflict_unclear` +- `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/project/README.md b/docs/project/README.md index 0677f18..7620bf3 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -30,7 +30,8 @@ | --- | --- | --- | | `requirements/M001-source-message-inbox-prd.md` | 当前有效 | M001 邮件来源入口 PRD,记录 AgentBus 邮件 JSON 入库、历史查询、原文读取和媒体引用边界。 | | `requirements/M002-order-task-workflow-v1.md` | 历史参考 | M002 订单任务主流程 V1,已由 V2 承接,保留用于理解早期流程。 | -| `requirements/M002-order-task-workflow-v2.md` | 当前有效 | M002 订单任务主流程 V2,记录 AI 过渡层、任务卡矩阵、系统主任务类型、临时订单、订单号候选和 OPERA 模拟回填边界。 | +| `requirements/M002-order-task-workflow-v2.md` | 阶段记录 | M002 订单任务主流程 V2,记录当前已阶段实现的 AI 过渡层、S000/S999 兼容、订单任务流转、任务确认和 OPERA 模拟骨架。 | +| `requirements/M002-order-task-workflow-v3.md` | 当前有效 | M002 订单任务主流程 V3,基于 2026-07-11 P0 冻结基线,记录 S10/S99、42 路由、方案 C、type-known manual review 同卡解阻和 fail-closed 边界。 | | `requirements/M002-superagent-task-result-api-contract.md` | 阶段记录 | M002 SuperAgent 任务结果入站接口契约阶段记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-ai-query-minimal-fields.md` | 阶段记录 | M002 SuperAgent 查询上下文接口 1、2 最小字段落地记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-backend-data-model-design.md` | 阶段记录 | M002 后端数据模型设计,记录 AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果表。 | @@ -67,6 +68,6 @@ - SuperAgent 对外 HTTP 接口以 `integrations/superagent-api-contract.md` 为权威来源。 - SuperAgent MCP 文档以 `integrations/superagent-mcp/` 为对外交付资料包,但字段语义应跟随 HTTP 总契约。 -- M002 V1 只作为历史参考;订单任务主流程以后续开发以 `requirements/M002-order-task-workflow-v2.md` 为准。 -- 前端展示 / 编辑字段以导入的前端字段表为白名单,后端完整校验和 OPERA 映射仍以任务卡完整矩阵和后端规则为准。 +- M002 V1 只作为历史参考;V2 记录当前阶段实现;后续 M002 新开发以 `requirements/M002-order-task-workflow-v3.md` 为开发基线。 +- 前端展示 / 编辑字段以 2026-07-11 P0 冻结基线中的前端字段表和路由说明为白名单,后端完整校验和 OPERA 映射仍以任务卡完整矩阵、0711 runtime 契约和后端规则为准。 - 时间点语义以 `backend-time-design.md` 为准;数据库时间点按 UTC 理解,API 返回带 `Z` 的 UTC 时间,页面再按酒店或用户时区展示。 diff --git a/docs/project/frontend-backend/debug-eml-page-integration-guide.md b/docs/project/frontend-backend/debug-eml-page-integration-guide.md index 8b2b812..c2c422d 100644 --- a/docs/project/frontend-backend/debug-eml-page-integration-guide.md +++ b/docs/project/frontend-backend/debug-eml-page-integration-guide.md @@ -35,7 +35,7 @@ Debug EML 页面第一版只做一件事: | 执行状态区 | loading、成功、失败、耗时、本次 `debug_run_id` | 提交后禁用按钮,避免重复点击;失败时展示安全错误摘要。 | | SourceMessage 追溯区 | `source_message_id`、`source_provider`、`external_message_id`、`external_conversation_id` | 用于确认已写入 SourceMessage Inbox。 | | 邮件内容预览区 | `html_body_sanitized`、纯文本、附件列表、内联图片列表 | HTML 展示必须优先使用 `html_body_sanitized`。 | -| SuperAgent 结果区 | `superagent_parsed_json`、`superagent_raw_answer`、`warnings[]` | JSON 可以格式化展示;S000/S999 会被后端识别成结构化入口结果;raw answer 用于排查其他非 JSON 输出。 | +| SuperAgent 结果区 | `superagent_parsed_json`、`superagent_raw_answer`、`warnings[]` | JSON 可以格式化展示;旧 S000/S999 和新 S10/S99 入口通知都不应被前端当成普通解析失败;raw answer 用于排查其他非 JSON 输出。 | | 调试 Payload 区 | `agentbus_like_payload` | 只用于调试展示,不让用户编辑后重新提交。 | ## 4. 接口 @@ -122,7 +122,7 @@ export async function uploadDebugEml(input: { | `superagent_session_id` | string | SuperAgent session ID。 | | `superagent_run_id` | string | SuperAgent run ID。 | | `superagent_raw_answer` | string | SuperAgent 最终原始文本回答。 | -| `superagent_parsed_json` | object/null | 后端尝试解析出的 JSON;S000/S999 会返回识别后的对象,其他解析失败时可能为空。 | +| `superagent_parsed_json` | object/null | 后端尝试解析出的 JSON;旧 S000/S999 会返回识别后的对象,0711 P0 后续结构化 S10/S99 会直接作为 JSON 展示,其他解析失败时可能为空。 | | `warnings[]` | string[] | 安全或解析警告,可在页面顶部或结果区展示。 | | `status` | string | Debug run 状态。 | @@ -145,9 +145,10 @@ export async function uploadDebugEml(input: { - `agentbus_like_payload.source.original_message_id` 是原始邮件 `Message-ID`。 - `external_message_id` 是 Debug 链路生成的独立 ID,不等同于原始 `Message-ID`。 - `superagent_parsed_json` 有值时优先展示格式化 JSON;没有值时展示 `superagent_raw_answer`。 -- 如果 SuperAgent 返回 `S000,source_message_id` 或 `S999,source_message_id`,后端会把它识别为特殊入口结果,不会作为 JSON 解析失败处理。前端可在结果区展示 `entry_result_code`、`entry_result_source_message_id`、`entry_result_meaning` 和 `entry_result_description`。 +- 如果 SuperAgent 返回旧 `S000,source_message_id` 或 `S999,source_message_id`,后端会把它识别为特殊入口结果,不会作为 JSON 解析失败处理。前端可在结果区展示 `entry_result_code`、`entry_result_source_message_id`、`entry_result_meaning` 和 `entry_result_description`。 +- 如果 SuperAgent 返回 0711 P0 新结构化 `S10/S99`,前端应按 JSON 展示 `route_code`、`result_type=source_message_review_notification`、`agent_assessment`、`notification` 和 S99 的入口 `manual_review`。 -S000/S999 解析示例: +旧 S000/S999 解析示例: ```json { @@ -158,6 +159,25 @@ S000/S999 解析示例: } ``` +V3 S10 结构化示例: + +```json +{ + "route_code": "S10", + "result_type": "source_message_review_notification", + "agent_assessment": { + "status": "no_booking_action_detected", + "reason_code": "no_booking_action_detected" + }, + "notification": { + "notification_type": "source_message_review", + "show_source_message": true, + "requires_user_decision": true + }, + "manual_review": null +} +``` + ## 6. 错误响应 错误响应结构: diff --git a/docs/project/integrations/superagent-mcp/tools.md b/docs/project/integrations/superagent-mcp/tools.md index 99041e7..c4f6117 100644 --- a/docs/project/integrations/superagent-mcp/tools.md +++ b/docs/project/integrations/superagent-mcp/tools.md @@ -346,6 +346,8 @@ POST /api/ai-query/v1/message-conversation/messages ### 7.4 输入 Schema +迁移提醒:当前 MCP tool 仍对应 M002 V2 的 `ai_task_results[]` 阶段契约。M002 V3 已确认迁移到结构化 `S10/S99` 和业务根 `message_events[]`,后续 MCP tool schema 必须跟随 `docs/project/integrations/superagent-api-contract.md` 和 `docs/project/requirements/M002-order-task-workflow-v3.md` 同步更新;在实现前不要把下方 schema 当作 V3 新入口。 + ```json { "type": "object", diff --git a/docs/project/requirements/M002-order-task-workflow-v1.md b/docs/project/requirements/M002-order-task-workflow-v1.md index 87823b7..986492e 100644 --- a/docs/project/requirements/M002-order-task-workflow-v1.md +++ b/docs/project/requirements/M002-order-task-workflow-v1.md @@ -1,7 +1,7 @@ # M002 Order Task Workflow 订单任务主流程 V1 -> 文档状态:历史参考。当前 M002 订单任务主流程已由 -> `M002-order-task-workflow-v2.md` 承接;后续开发、接口和测试优先以 V2 为准。 +> 文档状态:历史参考。当前 M002 后续开发基线已由 +> `M002-order-task-workflow-v3.md` 承接;V2 保留为当前阶段实现记录。 > 本文只用于理解早期流程讨论和边界来源。 ## 文档信息 diff --git a/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md index 29baa14..81ee731 100644 --- a/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md +++ b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md @@ -47,6 +47,7 @@ Inbox 作为来源事实,但不创建订单、不创建任务、不写 AI 任 - Debug EML 的 `external_message_id` 由后端生成,格式为 `debug-eml-run-{debugRunId}-{sha256前缀}`,避免同一封 `.eml` 多次上传被 SourceMessage 幂等覆盖。 - 原始邮件 `Message-ID` 不再作为 Debug EML 的 `external_message_id`,而是保存到 `agentbus_like_payload.source.original_message_id`。 - EML 会话解析支持 `References` / `In-Reply-To` / `Thread-Index`;缺失时再回退到当前消息 ID 或 debug 会话 ID。 +- Debug run 在解析、OSS 上传、构造 SourceMessage、写入 SourceMessage、调用 SuperAgent 前写入运行中阶段状态,便于测试环境定位卡点。 - SourceMessage 写入成功后先把 debug run 标记为 `SOURCE_CAPTURED`;即使后续 SuperAgent 调用失败,也保留 SourceMessage 和 OSS 原文追溯信息。 - HTML 正文复用邮件会话详情的 sanitize 策略,返回 `html_body_sanitized`、`html_sanitize_required` 和 `html_render_mode`。 - `cid:` 图片替换支持大小写不敏感的 scheme,并兼容常见的尖括号和 URL 编码尖括号形式。 @@ -92,14 +93,21 @@ Inbox 作为来源事实,但不创建订单、不创建任务、不写 AI 任 ```text Debug 页面上传 .eml → 后端校验 X-TH-Hotel-Debug-Upload-Key +→ debug run 标记为 PARSING_EML → 解析 MIME 邮件结构 +→ debug run 标记为 UPLOADING_ORIGINAL_EML → 上传原始 .eml 到阿里云 OSS +→ debug run 标记为 UPLOADING_MEDIA → 上传内联图片到阿里云 OSS +→ debug run 标记为 UPLOADING_MEDIA → 上传普通附件到阿里云 OSS +→ debug run 标记为 BUILDING_SOURCE_MESSAGE → 替换 HTML 正文中的 cid: 图片引用 → 组装 AgentBus-like payload +→ debug run 标记为 CAPTURING_SOURCE_MESSAGE → 调用 SourceMessageCaptureService 写入 SourceMessage Inbox → debug run 标记为 SOURCE_CAPTURED +→ debug run 标记为 CALLING_SUPERAGENT → 调用 SuperAgent Open API 创建 session → 调用 messages/stream 发送邮件 payload → 解析 SSE 最终回答 @@ -338,8 +346,8 @@ platform_debug_eml_superagent_run | `superagent_run_id` | SuperAgent run ID | | `superagent_raw_answer` | SuperAgent 最终文本回答 | | `superagent_parsed_json` | 后端解析出的 JSON | -| `run_status` | `CREATED`、`SOURCE_CAPTURED`、`SUPERAGENT_SUCCEEDED`、`SUPERAGENT_FAILED`、`FAILED` | -| `safe_error_summary` | 安全错误摘要,不包含正文、Secret 或附件签名 URL | +| `run_status` | `CREATED`、`PARSING_EML`、`UPLOADING_ORIGINAL_EML`、`UPLOADING_MEDIA`、`BUILDING_SOURCE_MESSAGE`、`CAPTURING_SOURCE_MESSAGE`、`SOURCE_CAPTURED`、`CALLING_SUPERAGENT`、`SUPERAGENT_SUCCEEDED`、`SUPERAGENT_FAILED`、`FAILED` | +| `safe_error_summary` | 运行中保存安全阶段摘要,失败时保存安全错误摘要;成功后清空为 `NULL`,不包含正文、Secret 或附件签名 URL | | `created_at` / `updated_at` | 创建和更新时间,按 UTC 写入 | 说明: diff --git a/scripts/superagent-direct-stream-test.sh b/scripts/superagent-direct-stream-test.sh new file mode 100755 index 0000000..d4644b3 --- /dev/null +++ b/scripts/superagent-direct-stream-test.sh @@ -0,0 +1,345 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Direct SuperAgent Open API stream test. +# Purpose: bypass TH Hotel backend Debug EML / OSS / SourceMessage and test +# whether SuperAgent Open API itself times out or returns usable SSE events. +# +# Usage on test server: +# cd /home/th-hotel-simple +# bash ./superagent-direct-stream-test.sh +# +# You can either edit the variables below, or override them before running: +# ENV_FILE=/home/th-hotel-simple/th-hotel-server.env bash ./superagent-direct-stream-test.sh + +####################################### +# Editable parameters +####################################### + +# Your test server env file. It can be plain key=value format. +ENV_FILE="${ENV_FILE:-/home/th-hotel-simple/th-hotel-server.env}" + +# Output directory for generated request bodies, response headers, and SSE body. +OUTPUT_DIR="${OUTPUT_DIR:-/tmp/th-hotel-superagent-direct}" + +# Curl timeout. This is total curl max time, not SuperAgent's own timeout. +TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-1900}" +CONNECT_TIMEOUT_SECONDS="${CONNECT_TIMEOUT_SECONDS:-30}" + +# Optional hard overrides. Leave empty to read from ENV_FILE variables. +BASE_URL="${BASE_URL:-}" +API_KEY="${API_KEY:-}" +EXTERNAL_SUBJECT_ID="${EXTERNAL_SUBJECT_ID:-}" + +# The direct test message. Keep it simple first; switch MESSAGE_PAYLOAD_FILE to +# a larger JSON later if you want to mimic Debug EML more closely. If you only +# have an .eml file, set EML_FILE and the script will embed the raw EML text in +# the message payload JSON. +MESSAGE_SUBJECT="${MESSAGE_SUBJECT:-timeout test}" +MESSAGE_TEXT="${MESSAGE_TEXT:-This is a direct curl timeout test. Please return a JSON result.}" +MESSAGE_PAYLOAD_FILE="${MESSAGE_PAYLOAD_FILE:-}" +EML_FILE="${EML_FILE:-}" +MESSAGE_PREFIX="${MESSAGE_PREFIX:-请基于以下 Debug 邮件 JSON 输出结构化任务抽取结果,只返回 JSON,不要创建订单或任务:}" + +####################################### +# Helpers +####################################### + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +mask_secret() { + local value="${1:-}" + if [ -z "$value" ]; then + echo "" + return + fi + if [ "${#value}" -le 8 ]; then + echo "****" + return + fi + echo "${value:0:4}****${value: -4}" +} + +load_env_file() { + if [ ! -f "$ENV_FILE" ]; then + echo "ENV_FILE not found: $ENV_FILE" >&2 + echo "Edit ENV_FILE at the top of this script, or pass ENV_FILE=/path/to/file." >&2 + exit 1 + fi + + # shellcheck disable=SC1090 + set -a + source "$ENV_FILE" + set +a +} + +normalize_config() { + BASE_URL="${BASE_URL:-${DEERFLOW_TEST_BASE_URL:-${DEERFLOW_BASE_URL:-}}}" + API_KEY="${API_KEY:-${DEERFLOW_TEST_OPEN_API_KEY:-${DEERFLOW_OPEN_API_KEY:-}}}" + if [ -z "$EXTERNAL_SUBJECT_ID" ]; then + EXTERNAL_SUBJECT_ID="${SUPERAGENT_TEST_DEBUG_EML_EXTERNAL_SUBJECT_ID:-${SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID:-th-hotel-debug-eml-upload}}" + fi + + if [ -z "$BASE_URL" ]; then + echo "BASE_URL is empty. Set DEERFLOW_TEST_BASE_URL / DEERFLOW_BASE_URL or edit BASE_URL." >&2 + exit 1 + fi + if [ -z "$API_KEY" ]; then + echo "API_KEY is empty. Set DEERFLOW_TEST_OPEN_API_KEY / DEERFLOW_OPEN_API_KEY or edit API_KEY." >&2 + exit 1 + fi + if [ -z "$EXTERNAL_SUBJECT_ID" ]; then + echo "EXTERNAL_SUBJECT_ID is empty. Set SUPERAGENT_TEST_DEBUG_EML_EXTERNAL_SUBJECT_ID or edit it." >&2 + exit 1 + fi + if [ -n "$MESSAGE_PAYLOAD_FILE" ] && [ ! -f "$MESSAGE_PAYLOAD_FILE" ]; then + echo "MESSAGE_PAYLOAD_FILE not found: $MESSAGE_PAYLOAD_FILE" >&2 + exit 1 + fi + if [ -n "$EML_FILE" ] && [ ! -f "$EML_FILE" ]; then + echo "EML_FILE not found: $EML_FILE" >&2 + exit 1 + fi + + BASE_URL="${BASE_URL%/}" +} + +print_config() { + echo "=== Direct SuperAgent stream test ===" + echo "ENV_FILE=$ENV_FILE" + echo "OUTPUT_DIR=$OUTPUT_DIR" + echo "BASE_URL=$BASE_URL" + echo "EXTERNAL_SUBJECT_ID=$EXTERNAL_SUBJECT_ID" + echo "API_KEY=$(mask_secret "$API_KEY")" + echo "TIMEOUT_SECONDS=$TIMEOUT_SECONDS" + echo "CONNECT_TIMEOUT_SECONDS=$CONNECT_TIMEOUT_SECONDS" + echo "MESSAGE_SUBJECT=$MESSAGE_SUBJECT" + if [ -n "$MESSAGE_PAYLOAD_FILE" ]; then + echo "MESSAGE_PAYLOAD_FILE=$MESSAGE_PAYLOAD_FILE" + fi + if [ -n "$EML_FILE" ]; then + echo "EML_FILE=$EML_FILE" + fi + echo +} + +write_create_body() { + python3 - <<'PY' > "$CREATE_BODY" +import json +import os + +run_id = os.environ["RUN_ID"] +subject = os.environ["EXTERNAL_SUBJECT_ID"] + +print(json.dumps({ + "external_subject_id": subject, + "idempotency_key": f"{run_id}-session", + "metadata": { + "source": "direct-curl", + "debug_run_id": run_id + } +}, ensure_ascii=False)) +PY +} + +write_stream_body() { + python3 - <<'PY' > "$STREAM_BODY" +import json +import os +from pathlib import Path + +run_id = os.environ["RUN_ID"] +payload_file = os.environ.get("MESSAGE_PAYLOAD_FILE", "").strip() +eml_file = os.environ.get("EML_FILE", "").strip() +prefix = os.environ["MESSAGE_PREFIX"] + +if payload_file: + payload_text = Path(payload_file).read_text(encoding="utf-8") +elif eml_file: + eml_path = Path(eml_file) + raw_eml = eml_path.read_text(encoding="utf-8", errors="replace") + payload_text = json.dumps({ + "source": "direct-curl", + "schema_version": "direct-raw-eml-v1", + "raw_eml_file_name": eml_path.name, + "raw_eml_size_bytes": eml_path.stat().st_size, + "raw_eml": raw_eml, + }, ensure_ascii=False) +else: + payload_text = json.dumps({ + "source": "direct-curl", + "subject": os.environ["MESSAGE_SUBJECT"], + "text": os.environ["MESSAGE_TEXT"], + }, ensure_ascii=False) + +message = prefix + "\n" + payload_text + +print(json.dumps({ + "message": message, + "idempotency_key": f"{run_id}-message", + "metadata": { + "source": "direct-curl", + "debug_run_id": run_id, + "source_message_id": "direct-curl", + "hotel_id": "DIRECT" + } +}, ensure_ascii=False)) +PY +} + +extract_session_id() { + python3 - "$CREATE_RESPONSE" <<'PY' +import json +import sys + +path = sys.argv[1] +try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) +except Exception: + print("") + raise SystemExit(0) + +print(data.get("session_id") or data.get("id") or "") +PY +} + +curl_create_session() { + local csrf="$RUN_ID-create" + echo "=== Creating SuperAgent session ===" + + set +e + CREATE_HTTP_CODE="$( + curl -sS \ + --connect-timeout "$CONNECT_TIMEOUT_SECONDS" \ + --max-time "$TIMEOUT_SECONDS" \ + -w "%{http_code}" \ + -D "$CREATE_HEADERS" \ + -o "$CREATE_RESPONSE" \ + -X POST "$BASE_URL/api/open/agent-sessions" \ + -H "Authorization: Bearer $API_KEY" \ + -H "X-CSRF-Token: $csrf" \ + -H "Cookie: csrf_token=$csrf" \ + -H "Content-Type: application/json" \ + --data-binary @"$CREATE_BODY" + )" + CREATE_CURL_EXIT=$? + set -e + + echo "create curl exit=$CREATE_CURL_EXIT http=$CREATE_HTTP_CODE" + echo "create headers: $CREATE_HEADERS" + echo "create response: $CREATE_RESPONSE" + + if [ "$CREATE_CURL_EXIT" -ne 0 ] || [ "$CREATE_HTTP_CODE" -lt 200 ] || [ "$CREATE_HTTP_CODE" -ge 300 ]; then + echo "Create session failed. Response body:" >&2 + cat "$CREATE_RESPONSE" >&2 || true + exit 1 + fi + + SESSION_ID="$(extract_session_id)" + if [ -z "$SESSION_ID" ]; then + echo "Create session response does not contain session_id or id. Body:" >&2 + cat "$CREATE_RESPONSE" >&2 || true + exit 1 + fi + + echo "SESSION_ID=$SESSION_ID" + echo +} + +curl_stream_message() { + local csrf="$RUN_ID-message" + echo "=== Calling SuperAgent message stream ===" + echo "This may run for a long time. Output is saved to: $STREAM_RESPONSE" + + local started_at ended_at elapsed + started_at="$(date +%s)" + + set +e + STREAM_HTTP_CODE="$( + curl -sS -N --no-buffer \ + --connect-timeout "$CONNECT_TIMEOUT_SECONDS" \ + --max-time "$TIMEOUT_SECONDS" \ + -w "%{http_code}" \ + -D "$STREAM_HEADERS" \ + -o "$STREAM_RESPONSE" \ + -X POST "$BASE_URL/api/open/agent-sessions/$SESSION_ID/messages/stream" \ + -H "Authorization: Bearer $API_KEY" \ + -H "X-CSRF-Token: $csrf" \ + -H "Cookie: csrf_token=$csrf" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + --data-binary @"$STREAM_BODY" + )" + STREAM_CURL_EXIT=$? + set -e + + ended_at="$(date +%s)" + elapsed="$((ended_at - started_at))" + + echo "stream curl exit=$STREAM_CURL_EXIT http=$STREAM_HTTP_CODE elapsed_seconds=$elapsed" + echo "stream headers: $STREAM_HEADERS" + echo "stream response: $STREAM_RESPONSE" + echo +} + +summarize_stream() { + echo "=== Stream headers ===" + cat "$STREAM_HEADERS" || true + echo + + echo "=== Stream event summary ===" + if [ -s "$STREAM_RESPONSE" ]; then + grep -E '^event:' "$STREAM_RESPONSE" | sort | uniq -c || true + echo + echo "Last 120 lines:" + tail -n 120 "$STREAM_RESPONSE" || true + else + echo "Stream response file is empty." + fi + echo +} + +####################################### +# Main +####################################### + +require_command curl +require_command python3 + +load_env_file +normalize_config + +RUN_ID="${RUN_ID:-direct-curl-$(date +%Y%m%d-%H%M%S)}" +export BASE_URL API_KEY EXTERNAL_SUBJECT_ID RUN_ID +export MESSAGE_SUBJECT MESSAGE_TEXT MESSAGE_PAYLOAD_FILE EML_FILE MESSAGE_PREFIX + +mkdir -p "$OUTPUT_DIR" + +CREATE_BODY="$OUTPUT_DIR/$RUN_ID-create-body.json" +CREATE_HEADERS="$OUTPUT_DIR/$RUN_ID-create.headers" +CREATE_RESPONSE="$OUTPUT_DIR/$RUN_ID-create.json" +STREAM_BODY="$OUTPUT_DIR/$RUN_ID-stream-body.json" +STREAM_HEADERS="$OUTPUT_DIR/$RUN_ID-stream.headers" +STREAM_RESPONSE="$OUTPUT_DIR/$RUN_ID-stream.sse" + +export CREATE_BODY STREAM_BODY CREATE_RESPONSE + +print_config +write_create_body +write_stream_body + +echo "create request body: $CREATE_BODY" +echo "stream request body: $STREAM_BODY" +echo + +curl_create_session +curl_stream_message +summarize_stream + +echo "Done." diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/enums/DebugEmlSuperAgentRunStatus.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/enums/DebugEmlSuperAgentRunStatus.java index f96d2f3..35e6f4b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/enums/DebugEmlSuperAgentRunStatus.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/enums/DebugEmlSuperAgentRunStatus.java @@ -6,7 +6,13 @@ package cn.nianxx.thhotel.platform.debug.common.enums; public enum DebugEmlSuperAgentRunStatus { CREATED, + PARSING_EML, + UPLOADING_ORIGINAL_EML, + UPLOADING_MEDIA, + BUILDING_SOURCE_MESSAGE, + CAPTURING_SOURCE_MESSAGE, SOURCE_CAPTURED, + CALLING_SUPERAGENT, SUPERAGENT_SUCCEEDED, SUPERAGENT_FAILED, FAILED diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/repository/MybatisDebugEmlSuperAgentRunRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/repository/MybatisDebugEmlSuperAgentRunRepository.java index d3ba841..e013e11 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/debug/repository/MybatisDebugEmlSuperAgentRunRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/repository/MybatisDebugEmlSuperAgentRunRepository.java @@ -45,29 +45,29 @@ public class MybatisDebugEmlSuperAgentRunRepository implements DebugEmlSuperAgen */ @Override public void updateResult(DebugEmlSuperAgentRunUpdate update) { - DebugEmlSuperAgentRunEntity entity = new DebugEmlSuperAgentRunEntity(); - entity.setId(update.id()); - entity.setSourceMessageId(update.sourceMessageId()); - entity.setExternalMessageId(update.externalMessageId()); - entity.setExternalConversationId(update.externalConversationId()); - entity.setOriginalFileName(update.originalFileName()); - entity.setOriginalEmlOssUrl(update.originalEmlOssUrl()); - entity.setOriginalEmlSha256(update.originalEmlSha256()); - entity.setPayloadJson(update.payloadJson()); - entity.setSuperagentSessionId(update.superagentSessionId()); - entity.setSuperagentRunId(update.superagentRunId()); - entity.setSuperagentProfileId(update.superagentProfileId()); - entity.setSuperagentProfileVersionId(update.superagentProfileVersionId()); - entity.setSuperagentModelName(update.superagentModelName()); - entity.setSuperagentRawAnswer(update.superagentRawAnswer()); - entity.setSuperagentParsedJson(update.superagentParsedJson()); - entity.setSuperagentInputTokens(update.superagentInputTokens()); - entity.setSuperagentOutputTokens(update.superagentOutputTokens()); - entity.setSuperagentTotalTokens(update.superagentTotalTokens()); - entity.setRunStatus(update.runStatus()); - entity.setSafeErrorSummary(update.safeErrorSummary()); - entity.setUpdatedAt(update.updatedAt()); - runMapper.updateById(entity); + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(DebugEmlSuperAgentRunEntity::getId, update.id()) + .set(DebugEmlSuperAgentRunEntity::getSourceMessageId, update.sourceMessageId()) + .set(DebugEmlSuperAgentRunEntity::getExternalMessageId, update.externalMessageId()) + .set(DebugEmlSuperAgentRunEntity::getExternalConversationId, update.externalConversationId()) + .set(DebugEmlSuperAgentRunEntity::getOriginalFileName, update.originalFileName()) + .set(DebugEmlSuperAgentRunEntity::getOriginalEmlOssUrl, update.originalEmlOssUrl()) + .set(DebugEmlSuperAgentRunEntity::getOriginalEmlSha256, update.originalEmlSha256()) + .set(DebugEmlSuperAgentRunEntity::getPayloadJson, update.payloadJson()) + .set(DebugEmlSuperAgentRunEntity::getSuperagentSessionId, update.superagentSessionId()) + .set(DebugEmlSuperAgentRunEntity::getSuperagentRunId, update.superagentRunId()) + .set(DebugEmlSuperAgentRunEntity::getSuperagentProfileId, update.superagentProfileId()) + .set(DebugEmlSuperAgentRunEntity::getSuperagentProfileVersionId, update.superagentProfileVersionId()) + .set(DebugEmlSuperAgentRunEntity::getSuperagentModelName, update.superagentModelName()) + .set(DebugEmlSuperAgentRunEntity::getSuperagentRawAnswer, update.superagentRawAnswer()) + .set(DebugEmlSuperAgentRunEntity::getSuperagentParsedJson, update.superagentParsedJson()) + .set(DebugEmlSuperAgentRunEntity::getSuperagentInputTokens, update.superagentInputTokens()) + .set(DebugEmlSuperAgentRunEntity::getSuperagentOutputTokens, update.superagentOutputTokens()) + .set(DebugEmlSuperAgentRunEntity::getSuperagentTotalTokens, update.superagentTotalTokens()) + .set(DebugEmlSuperAgentRunEntity::getRunStatus, update.runStatus()) + .set(DebugEmlSuperAgentRunEntity::getSafeErrorSummary, update.safeErrorSummary()) + .set(DebugEmlSuperAgentRunEntity::getUpdatedAt, update.updatedAt()); + runMapper.update(null, wrapper); } /** diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java index 687067e..8c2aa65 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java @@ -252,6 +252,10 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe byte[] emlBytes, LocalDateTime createdAt) throws Exception { String sha256 = sha256(emlBytes); + markStage( + runId, + DebugEmlSuperAgentRunStatus.PARSING_EML, + "阶段:解析 EML 邮件,文件名:" + safeFileName + ",大小:" + emlBytes.length + " bytes。"); ParsedEmlMessage parsed = parseService.parse(emlBytes, safeFileName); String originalMessageId = parsed.messageId(); String originalConversationId = parsed.conversationId(); @@ -260,17 +264,33 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe List warnings = new ArrayList<>(); List uploadedMedia = new ArrayList<>(); + markStage( + runId, + DebugEmlSuperAgentRunStatus.UPLOADING_ORIGINAL_EML, + "阶段:上传原始 EML 到 OSS,文件名:" + safeFileName + ",大小:" + emlBytes.length + " bytes。"); uploadedMedia.add(uploadOriginalEml(runId, safeFileName, emlBytes, createdAt)); int inlineIndex = 1; int attachmentIndex = 1; for (ParsedEmlMediaItem mediaItem : parsed.mediaItems()) { if (SourceMessageMediaType.INLINE_IMAGE.code().equals(mediaItem.mediaType())) { + markStage( + runId, + DebugEmlSuperAgentRunStatus.UPLOADING_MEDIA, + mediaUploadStageSummary(mediaItem, "inline", inlineIndex)); uploadedMedia.add(uploadParsedMedia(runId, createdAt, mediaItem, "inline", inlineIndex++)); } else { + markStage( + runId, + DebugEmlSuperAgentRunStatus.UPLOADING_MEDIA, + mediaUploadStageSummary(mediaItem, "attachments", attachmentIndex)); uploadedMedia.add(uploadParsedMedia(runId, createdAt, mediaItem, "attachments", attachmentIndex++)); } } + markStage( + runId, + DebugEmlSuperAgentRunStatus.BUILDING_SOURCE_MESSAGE, + "阶段:构造 SourceMessage payload。"); String htmlWithOssUrls = replaceCidReferences(parsed.htmlBody(), uploadedMedia, warnings); String htmlBodySanitized = htmlSanitizerService.sanitizeHtml(htmlWithOssUrls); String htmlRenderMode = htmlSanitizerService.htmlRenderMode(htmlWithOssUrls); @@ -285,6 +305,10 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe htmlWithOssUrls, uploadedMedia); String payloadJson = objectMapper.writeValueAsString(payload); + markStage( + runId, + DebugEmlSuperAgentRunStatus.CAPTURING_SOURCE_MESSAGE, + "阶段:写入 SourceMessage Inbox。"); SourceMessageCaptureResult captureResult = sourceMessageCaptureService.capture(new CaptureSourceMessageCommand( hotelId, SOURCE_PROVIDER, @@ -311,6 +335,10 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe sha256, payloadJson); + markStage( + runId, + DebugEmlSuperAgentRunStatus.CALLING_SUPERAGENT, + "阶段:调用 SuperAgent Open API,source_message_id=" + captureResult.inboxId() + "。"); SuperAgentOpenApiResult superAgentResult = superAgentOpenApiClient.invokeMailDebug(new SuperAgentMailDebugRequest( buildSuperAgentMessage(payloadJson), "debug-eml-" + runId, @@ -658,6 +686,23 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe return result; } + /** + * 标记 Debug 运行中的当前阶段。这里复用安全摘要字段保存短文本面包屑,便于测试环境定位卡点。 + */ + private void markStage( + Long runId, + DebugEmlSuperAgentRunStatus status, + String safeSummary) { + if (runId == null) { + return; + } + runRepository.updateStatus(new DebugEmlSuperAgentRunStatusUpdate( + runId, + status.name(), + truncate(safeSummary, 512), + nowUtc())); + } + /** * 标记 Debug 运行失败。 */ @@ -676,6 +721,18 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe updatedAt)); } + /** + * 构造媒体上传阶段摘要,只包含安全元数据,不包含正文、公开 URL 或二进制内容。 + */ + private String mediaUploadStageSummary(ParsedEmlMediaItem mediaItem, String folder, int index) { + String fileName = safeFileName(mediaItem.fileName(), folder + "-" + index); + return "阶段:上传邮件媒体到 OSS,目录:" + folder + + ",序号:" + index + + ",类型:" + mediaItem.mediaType() + + ",文件名:" + fileName + + ",大小:" + mediaItem.sizeBytes() + " bytes。"; + } + /** * 根据异常选择 Debug run 状态。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelAccessServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelAccessServiceImpl.java index 3fac859..a71bc35 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelAccessServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelAccessServiceImpl.java @@ -71,6 +71,9 @@ public class HotelAccessServiceImpl implements HotelAccessService { .orElseGet(() -> hotels.isEmpty() ? null : hotels.get(0).hotelId()); } + /** + * 解析当前用户可访问酒店;超级管理员取全部启用酒店,普通用户取授权关系。 + */ private Set resolveAccessibleHotelIds(PlatformUserEntity user, List activeHotels) { if (Boolean.TRUE.equals(user.getSuperAdmin())) { return new LinkedHashSet<>(activeHotels.stream() @@ -80,6 +83,9 @@ public class HotelAccessServiceImpl implements HotelAccessService { return new LinkedHashSet<>(hotelRepository.listUserHotelIds(user.getId())); } + /** + * 按前端偏好、用户默认酒店、系统默认酒店的优先级解析当前默认酒店。 + */ private String resolveDefaultHotelId( PlatformUserEntity user, Set accessibleHotelIds, diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthServiceImpl.java index 75e9ef1..22d5be5 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthServiceImpl.java @@ -161,6 +161,9 @@ public class AuthServiceImpl implements AuthService { }); } + /** + * 组装当前用户上下文响应,登录成功和 /me 接口共用同一套权限、酒店和菜单逻辑。 + */ private AuthMeResult buildMeResult(PlatformUserEntity user, String preferredHotelId) { List hotels = hotelAccessService.listAccessibleHotels(user, preferredHotelId); String defaultHotelId = hotelAccessService.resolveDefaultHotelId(hotels); @@ -180,6 +183,9 @@ public class AuthServiceImpl implements AuthService { menus); } + /** + * 构建请求线程内的用户上下文,供后续业务接口审计和权限收口复用。 + */ private AuthenticatedUserContext buildSecurityContext(PlatformUserEntity user) { List hotels = hotelAccessService.listAccessibleHotels(user, null); List permissions = accessControlService.listPermissionCodes( @@ -195,11 +201,17 @@ public class AuthServiceImpl implements AuthService { permissions); } + /** + * 强制解析当前 token 对应 session;登录态恢复接口必须拿到有效 session。 + */ private AuthSessionSnapshot resolveRequiredSession(String authorizationHeader) { String token = extractRequiredBearerToken(authorizationHeader); return resolveSessionByToken(token, true).orElseThrow(this::invalidSession); } + /** + * 根据明文 token 查找有效 session,并按调用场景决定是否抛出无效登录态异常。 + */ private Optional resolveSessionByToken(String token, boolean strict) { String tokenHash = tokenService.hashToken(token); Optional session = identityRepository.findSessionByTokenHash(tokenHash); @@ -222,6 +234,9 @@ public class AuthServiceImpl implements AuthService { return user.map(value -> new AuthSessionSnapshot(existingSession.getId(), existingSession.getExpiresAt(), value)); } + /** + * 刷新 session 最近访问时间;只更新访问时间字段,避免覆盖 token 安全字段。 + */ private void touchSession(Long sessionId) { if (sessionId == null) { return; @@ -233,16 +248,25 @@ public class AuthServiceImpl implements AuthService { identityRepository.updateSession(session); } + /** + * 将过期 session 标记为 EXPIRED,避免后续重复按 ACTIVE 处理。 + */ private void markExpired(PlatformUserSessionEntity session) { session.setSessionStatus(PlatformUserSessionStatus.EXPIRED.name()); session.setUpdatedAt(nowUtc()); identityRepository.updateSession(session); } + /** + * 判断用户是否为可登录状态,禁用用户不能登录或继续使用旧 session。 + */ private boolean isActiveUser(PlatformUserEntity user) { return PlatformUserStatus.ACTIVE.name().equals(user.getUserStatus()); } + /** + * 解析必须存在的 Bearer token;缺失时返回受控 401。 + */ private String extractRequiredBearerToken(String authorizationHeader) { return extractBearerToken(authorizationHeader) .orElseThrow(() -> new AuthException( @@ -251,6 +275,9 @@ public class AuthServiceImpl implements AuthService { "请先登录。")); } + /** + * 从 Authorization 请求头中解析 Bearer token;无效格式按空 token 处理。 + */ private Optional extractBearerToken(String authorizationHeader) { if (authorizationHeader == null || authorizationHeader.isBlank()) { return Optional.empty(); @@ -263,6 +290,9 @@ public class AuthServiceImpl implements AuthService { return token.isBlank() ? Optional.empty() : Optional.of(token); } + /** + * 构建统一的 session 失效异常,避免向前端泄露 token 或账号内部状态。 + */ private AuthException invalidSession() { return new AuthException( HttpStatus.UNAUTHORIZED, @@ -270,10 +300,16 @@ public class AuthServiceImpl implements AuthService { "登录已失效,请重新登录。"); } + /** + * 获取 UTC 当前时间,数据库 LocalDateTime 统一按 UTC 语义保存。 + */ private LocalDateTime nowUtc() { return LocalDateTime.now(ZoneOffset.UTC); } + /** + * 提取客户端 IP 摘要,第一版只保留请求直接来源。 + */ private String clientIp(HttpServletRequest request) { if (request == null) { return null; @@ -285,6 +321,9 @@ public class AuthServiceImpl implements AuthService { return truncate(request.getRemoteAddr(), 64); } + /** + * 提取 User-Agent 摘要,避免过长请求头直接进入 session 表。 + */ private String userAgent(HttpServletRequest request) { if (request == null) { return null; @@ -292,6 +331,9 @@ public class AuthServiceImpl implements AuthService { return truncate(request.getHeader("User-Agent"), 256); } + /** + * 截断外部输入摘要字段,保护数据库字段长度和日志可读性。 + */ private String truncate(String value, int maxLength) { if (value == null || value.length() <= maxLength) { return value; diff --git a/server/src/main/resources/application-dev.yml b/server/src/main/resources/application-dev.yml index b577e28..5c6f56d 100644 --- a/server/src/main/resources/application-dev.yml +++ b/server/src/main/resources/application-dev.yml @@ -9,9 +9,9 @@ spring: enabled: true servlet: multipart: - # dev Debug EML multipart 上限必须不小于业务文件上限,避免请求进入 Controller 前被 413 拦截。 - max-file-size: ${DEBUG_EML_UPLOAD_DEV_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} - max-request-size: ${DEBUG_EML_UPLOAD_DEV_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:12582912}} + # dev multipart 上限高于业务上限,超限文件由 Debug EML 服务层返回受控 JSON 错误。 + max-file-size: ${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}} + max-request-size: ${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_DEV_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}} source-message: original-read: diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index 2831b77..cbcf229 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -9,9 +9,9 @@ spring: enabled: true servlet: multipart: - # prod Debug EML 如被显式开启,multipart 上限必须先放行到业务文件上限,再由服务层做受控校验。 - max-file-size: ${DEBUG_EML_UPLOAD_PROD_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} - max-request-size: ${DEBUG_EML_UPLOAD_PROD_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:12582912}} + # prod Debug EML 如被显式开启,multipart 上限应高于业务上限,便于服务层返回受控错误。 + max-file-size: ${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}} + max-request-size: ${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_PROD_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}} source-message: original-read: diff --git a/server/src/main/resources/application-test.yml b/server/src/main/resources/application-test.yml index 6a3fbae..51554b2 100644 --- a/server/src/main/resources/application-test.yml +++ b/server/src/main/resources/application-test.yml @@ -9,9 +9,9 @@ spring: enabled: true servlet: multipart: - # test Debug EML multipart 上限必须不小于业务文件上限,避免请求进入 Controller 前被 413 拦截。 - max-file-size: ${DEBUG_EML_UPLOAD_TEST_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} - max-request-size: ${DEBUG_EML_UPLOAD_TEST_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:12582912}} + # test multipart 上限高于业务上限,超限文件由 Debug EML 服务层返回受控 JSON 错误。 + max-file-size: ${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}} + max-request-size: ${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_TEST_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}} source-message: original-read: diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index f35dd1f..aa0262f 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -8,9 +8,9 @@ spring: enabled: true servlet: multipart: - # Debug EML 默认允许 10MB 文件;request 上限略大于文件上限,给 multipart 边界和字段留空间。 - max-file-size: ${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760} - max-request-size: ${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:12582912} + # multipart 需要高于 Debug EML 业务文件上限,避免超限文件在进入 Controller 前被框架直接 413 拦截。 + max-file-size: ${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520} + max-request-size: ${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}} mybatis-plus: configuration: diff --git a/server/src/main/resources/db/migration/V13__make_existing_string_columns_case_sensitive.sql b/server/src/main/resources/db/migration/V13__make_existing_string_columns_case_sensitive.sql new file mode 100644 index 0000000..dd8992a --- /dev/null +++ b/server/src/main/resources/db/migration/V13__make_existing_string_columns_case_sensitive.sql @@ -0,0 +1,64 @@ +-- M001/M003 数据库字符排序规则加固:统一把现有字符串列改为大小写敏感。 +-- 背景:外部系统 opaque id 可能只差大小写,例如 Outlook external_message_id 中的 X/x。 +-- MySQL 默认 *_ci collation 会把这类值当成相同字符串,导致幂等查询和唯一键误判。 +-- 说明:以下 MySQL 版本注释在 MySQL 8.0+ 会执行;H2 MySQL Mode 会把它们当注释跳过,避免本地 H2 测试库不支持 utf8mb4_bin 语法。 + +-- SourceMessage:来源消息、正文、媒体、原文审计和重复投递诊断。 +/*!80000 ALTER TABLE platform_source_message_inbox DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_source_message_inbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_source_message_inbox + MODIFY COLUMN external_message_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '外部邮件系统中的单封邮件唯一 ID,对应 AgentBus source.external_message_id;解析失败时允许为空以保留失败记录' */; +/*!80000 ALTER TABLE platform_source_message_payload DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_source_message_payload CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_source_message_body DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_source_message_body CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_source_message_media DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_source_message_media CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_source_message_original_access_audit DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_source_message_original_access_audit CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_source_message_payload_duplicate DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_source_message_payload_duplicate CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; + +-- SuperAgent / Reservation:AI 入站、订单、任务、任务卡、审计和 OPERA 模拟。 +/*!80000 ALTER TABLE integration_superagent_task_result_nonce DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE integration_superagent_task_result_nonce CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_ai_batch DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_ai_batch CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_ai_transition DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_ai_transition CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_order DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_order CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_task DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_task CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_task_card DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_task_card CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_audit_log DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_audit_log CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_opera_operation DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_opera_operation CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_opera_operation_attempt DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE workflow_reservation_opera_operation_attempt CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; + +-- Debug EML:调试上传链路中的外部 ID、OSS URL、SuperAgent 运行标识和状态。 +/*!80000 ALTER TABLE platform_debug_eml_superagent_run DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_debug_eml_superagent_run CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; + +-- Identity / Access / Hotel / Menu:用户、角色、权限、酒店和菜单基础表。 +/*!80000 ALTER TABLE platform_user DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_user CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_user_session DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_user_session CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_role DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_role CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_permission DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_permission CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_user_role DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_user_role CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_role_permission DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_role_permission CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_hotel DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_hotel CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_user_hotel DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_user_hotel CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_menu DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; +/*!80000 ALTER TABLE platform_menu CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */; diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/access/repository/MybatisPlatformAccessRepositoryTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/access/repository/MybatisPlatformAccessRepositoryTest.java new file mode 100644 index 0000000..9f6c0e4 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/access/repository/MybatisPlatformAccessRepositoryTest.java @@ -0,0 +1,57 @@ +package cn.nianxx.thhotel.platform.access.repository; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import cn.nianxx.thhotel.platform.access.domain.PlatformRolePermissionEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformUserRoleEntity; +import cn.nianxx.thhotel.platform.access.mapper.PlatformPermissionMapper; +import cn.nianxx.thhotel.platform.access.mapper.PlatformRoleMapper; +import cn.nianxx.thhotel.platform.access.mapper.PlatformRolePermissionMapper; +import cn.nianxx.thhotel.platform.access.mapper.PlatformUserRoleMapper; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings({"rawtypes", "unchecked"}) +class MybatisPlatformAccessRepositoryTest { + + @Mock + private PlatformRoleMapper roleMapper; + @Mock + private PlatformPermissionMapper permissionMapper; + @Mock + private PlatformUserRoleMapper userRoleMapper; + @Mock + private PlatformRolePermissionMapper rolePermissionMapper; + + @Test + void shouldIgnoreDuplicateKeyWhenEnsuringRolePermission() { + MybatisPlatformAccessRepository repository = repository(); + when(rolePermissionMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + doThrow(new DuplicateKeyException("duplicate role permission")) + .when(rolePermissionMapper).insert(any(PlatformRolePermissionEntity.class)); + + assertThatNoException().isThrownBy(() -> repository.ensureRolePermission(1L, 2L)); + } + + @Test + void shouldIgnoreDuplicateKeyWhenEnsuringUserRole() { + MybatisPlatformAccessRepository repository = repository(); + when(userRoleMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + doThrow(new DuplicateKeyException("duplicate user role")) + .when(userRoleMapper).insert(any(PlatformUserRoleEntity.class)); + + assertThatNoException().isThrownBy(() -> repository.ensureUserRole(1L, 2L)); + } + + private MybatisPlatformAccessRepository repository() { + return new MybatisPlatformAccessRepository(roleMapper, permissionMapper, userRoleMapper, rolePermissionMapper); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java index 05be90f..cbeaeca 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java @@ -21,6 +21,7 @@ import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStor import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -152,10 +153,63 @@ class DebugEmlSuperAgentControllerTest { AND superagent_session_id = 'session-debug-001' AND superagent_run_id = 'run-debug-001' AND run_label = 'controller-test' + AND safe_error_summary IS NULL """, Long.class); org.assertj.core.api.Assertions.assertThat(debugRunCount).isEqualTo(1L); } + @Test + void shouldRecordPhaseBeforeUploadingOriginalEmlToOss() throws Exception { + AtomicInteger uploadIndex = new AtomicInteger(); + when(objectStorageService.putObject(any())).thenAnswer(invocation -> { + ObjectStoragePutRequest request = invocation.getArgument(0); + if (uploadIndex.incrementAndGet() == 1) { + Long phaseCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_debug_eml_superagent_run + WHERE run_label = 'phase-debug-upload' + AND run_status = 'UPLOADING_ORIGINAL_EML' + AND safe_error_summary LIKE '%上传原始 EML%' + """, Long.class); + org.assertj.core.api.Assertions.assertThat(phaseCount).isEqualTo(1L); + } + return new ObjectStoragePutResult( + request.objectKey(), + "https://oss.example.test/" + request.objectKey(), + request.contentType(), + request.sizeBytes()); + }); + when(superAgentOpenApiClient.invokeMailDebug(any())).thenAnswer(invocation -> { + Long phaseCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_debug_eml_superagent_run + WHERE run_label = 'phase-debug-upload' + AND run_status = 'CALLING_SUPERAGENT' + AND safe_error_summary LIKE '%调用 SuperAgent Open API%' + """, Long.class); + org.assertj.core.api.Assertions.assertThat(phaseCount).isEqualTo(1L); + return new SuperAgentOpenApiResult( + "session-debug-phase", + "run-debug-phase", + "profile-debug", + "profile-version-debug", + "debug-model", + "{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}", + 11, + 7, + 18, + List.of("metadata", "values", "end")); + }); + + mockMvc.perform(multipart(ENDPOINT) + .file(emlFile()) + .param("hotel_id", "HOTEL-TEST") + .param("run_label", "phase-debug-upload") + .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("SUPERAGENT_SUCCEEDED")); + } + @Test void shouldCreateIndependentSourceMessageForRepeatedDebugUpload() throws Exception { mockStorageAndSuperAgentSuccess(); diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/debug/service/DebugEmlMultipartConfigurationTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/debug/service/DebugEmlMultipartConfigurationTest.java index d4a5ad6..98a5a65 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/debug/service/DebugEmlMultipartConfigurationTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/debug/service/DebugEmlMultipartConfigurationTest.java @@ -23,9 +23,9 @@ class DebugEmlMultipartConfigurationTest { private DebugEmlSuperAgentProperties debugEmlProperties; @Test - void shouldAllowMultipartRequestBeforeDebugEmlBusinessLimit() { + void shouldLeaveHeadroomForDebugEmlBusinessLimit() { assertThat(multipartProperties.getMaxFileSize().toBytes()) - .isGreaterThanOrEqualTo(debugEmlProperties.getMaxFileBytes()); + .isGreaterThan(debugEmlProperties.getMaxFileBytes()); assertThat(multipartProperties.getMaxRequestSize().toBytes()) .isGreaterThan(debugEmlProperties.getMaxFileBytes()); } diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/identity/repository/PlatformIdentityRepositoryTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/identity/repository/PlatformIdentityRepositoryTest.java new file mode 100644 index 0000000..bebb9fb --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/identity/repository/PlatformIdentityRepositoryTest.java @@ -0,0 +1,62 @@ +package cn.nianxx.thhotel.platform.identity.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import java.time.LocalDateTime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:m003_identity_repository_review;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=", + "auth.bootstrap.admin.password=" + }) +@ActiveProfiles("test") +class PlatformIdentityRepositoryTest { + + @Autowired + private PlatformIdentityRepository identityRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @AfterEach + void cleanReviewRows() { + jdbcTemplate.update("DELETE FROM platform_user WHERE username LIKE 'm003-review-%'"); + } + + @Test + void shouldOnlyCountActiveSuperAdminUsers() { + long baseline = identityRepository.countSuperAdminUsers(); + identityRepository.insertUser(user("m003-review-disabled-super-admin", PlatformUserStatus.DISABLED.name(), true)); + + assertThat(identityRepository.countSuperAdminUsers()).isEqualTo(baseline); + + identityRepository.insertUser(user("m003-review-active-super-admin", PlatformUserStatus.ACTIVE.name(), true)); + + assertThat(identityRepository.countSuperAdminUsers()).isEqualTo(baseline + 1); + } + + private PlatformUserEntity user(String username, String status, boolean superAdmin) { + LocalDateTime now = LocalDateTime.now(); + PlatformUserEntity user = new PlatformUserEntity(); + user.setUsername(username); + user.setPasswordHash("{bcrypt}placeholder"); + user.setDisplayName(username); + user.setUserStatus(status); + user.setSuperAdmin(superAdmin); + user.setPasswordChangedAt(now); + user.setCreatedAt(now); + user.setUpdatedAt(now); + return user; + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunnerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunnerTest.java new file mode 100644 index 0000000..454eb85 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunnerTest.java @@ -0,0 +1,104 @@ +package cn.nianxx.thhotel.platform.identity.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import cn.nianxx.thhotel.ThHotelApplication; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:m003_bootstrap_review;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=", + "auth.bootstrap.admin.password=", + "auth.bootstrap.default-hotel-id=HOTEL-TEST", + "auth.bootstrap.default-hotel-name=测试酒店" + }) +@ActiveProfiles("test") +class PlatformIdentityBootstrapRunnerTest { + + @Autowired + private PlatformIdentityBootstrapRunner bootstrapRunner; + + @Autowired + private AuthProperties authProperties; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void cleanReviewRows() { + authProperties.getBootstrap().getAdmin().setUsername(""); + authProperties.getBootstrap().getAdmin().setPassword(""); + authProperties.getBootstrap().getAdmin().setDisplayName("系统管理员"); + jdbcTemplate.update("DELETE FROM platform_user_role WHERE user_id IN (SELECT id FROM platform_user WHERE username LIKE 'm003-review-%')"); + jdbcTemplate.update("DELETE FROM platform_user_hotel WHERE user_id IN (SELECT id FROM platform_user WHERE username LIKE 'm003-review-%')"); + jdbcTemplate.update("DELETE FROM platform_user WHERE username LIKE 'm003-review-%'"); + jdbcTemplate.update("DELETE FROM platform_role_permission WHERE role_id IN (SELECT id FROM platform_role WHERE role_code = 'RESERVATION_VIEWER') AND permission_id IN (SELECT id FROM platform_permission WHERE permission_code = 'SYSTEM_DEBUG_EML_RUN')"); + } + + @Test + void shouldCreateBootstrapAdminWhenOnlyDisabledSuperAdminExists() { + LocalDateTime now = LocalDateTime.now(); + jdbcTemplate.update(""" + INSERT INTO platform_user ( + id, username, password_hash, display_name, user_status, super_admin, + password_changed_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + 9003001001L, + "m003-review-disabled-admin", + "{bcrypt}disabled", + "Disabled Admin", + "DISABLED", + true, + now, + now, + now); + authProperties.getBootstrap().getAdmin().setUsername("m003-review-recovered-admin"); + authProperties.getBootstrap().getAdmin().setPassword("Recovered@123456"); + authProperties.getBootstrap().getAdmin().setDisplayName("恢复管理员"); + + bootstrapRunner.run((ApplicationArguments) null); + + Integer activeSuperAdminCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_user + WHERE username = 'm003-review-recovered-admin' + AND user_status = 'ACTIVE' + AND super_admin = 1 + """, Integer.class); + assertThat(activeSuperAdminCount).isEqualTo(1); + } + + @Test + void shouldRemoveStaleBuiltInRolePermissionsDuringBootstrap() { + Long viewerRoleId = jdbcTemplate.queryForObject(""" + SELECT id FROM platform_role WHERE role_code = 'RESERVATION_VIEWER' + """, Long.class); + Long debugPermissionId = jdbcTemplate.queryForObject(""" + SELECT id FROM platform_permission WHERE permission_code = 'SYSTEM_DEBUG_EML_RUN' + """, Long.class); + jdbcTemplate.update(""" + INSERT INTO platform_role_permission (id, role_id, permission_id, created_at) + VALUES (?, ?, ?, ?) + """, 9003002001L, viewerRoleId, debugPermissionId, LocalDateTime.now()); + + bootstrapRunner.run((ApplicationArguments) null); + + Integer staleCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_role_permission + WHERE role_id = ? + AND permission_id = ? + """, Integer.class, viewerRoleId, debugPermissionId); + assertThat(staleCount).isZero(); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java index fd7283d..69fa2d5 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java @@ -25,6 +25,8 @@ import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepositor import cn.nianxx.thhotel.platform.message.service.impl.SourceMessageCaptureServiceImpl; import cn.nianxx.thhotel.platform.message.service.impl.SourceMessageSafetySanitizer; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.LocalDateTime; import java.util.List; @@ -33,6 +35,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.dao.DuplicateKeyException; +import org.springframework.core.io.ClassPathResource; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; @@ -221,6 +224,78 @@ class SourceMessageCaptureServiceImplTest { assertThat(inbox.getUpdatedAt()).isEqualTo(inbox.getCreatedAt()); } + @Test + void shouldCaptureCaseVariantExternalMessageIdsAsDifferentMessages() { + CaptureSourceMessageCommand upperCaseCommand = new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + "mail-case-sensitive-X", + "conversation-case-sensitive", + "frame-case-sensitive-001", + "session-case-sensitive", + Instant.parse("2026-07-06T09:20:00Z"), + "guest@example.test", + "Case sensitive delivery", + "Upper case external id", + "Upper case external id", + "{\"source\":{\"external_message_id\":\"mail-case-sensitive-X\"},\"version\":1}", + "agentbus-outlook-v1", + List.of() + ); + CaptureSourceMessageCommand lowerCaseCommand = new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + "mail-case-sensitive-x", + "conversation-case-sensitive", + "frame-case-sensitive-002", + "session-case-sensitive", + Instant.parse("2026-07-06T09:21:00Z"), + "guest@example.test", + "Case sensitive delivery", + "Lower case external id", + "Lower case external id", + "{\"source\":{\"external_message_id\":\"mail-case-sensitive-x\"},\"version\":1}", + "agentbus-outlook-v1", + List.of() + ); + + SourceMessageCaptureResult upperCaseResult = captureService.capture(upperCaseCommand); + SourceMessageCaptureResult lowerCaseResult = captureService.capture(lowerCaseCommand); + + assertThat(upperCaseResult.created()).isTrue(); + assertThat(lowerCaseResult.created()).isTrue(); + assertThat(lowerCaseResult.inboxId()).isNotEqualTo(upperCaseResult.inboxId()); + + List externalMessageIds = inboxMapper.selectList(Wrappers.lambdaQuery() + .eq(SourceMessageInboxEntity::getHotelId, "HOTEL-TEST") + .eq(SourceMessageInboxEntity::getProvider, "AGENTBUS") + .eq(SourceMessageInboxEntity::getChannel, "EMAIL") + .eq(SourceMessageInboxEntity::getExternalConversationId, "conversation-case-sensitive") + .orderByAsc(SourceMessageInboxEntity::getExternalMessageId)) + .stream() + .map(SourceMessageInboxEntity::getExternalMessageId) + .toList(); + assertThat(externalMessageIds) + .containsExactlyInAnyOrder("mail-case-sensitive-X", "mail-case-sensitive-x"); + } + + @Test + void shouldProvideMysqlMigrationForCaseSensitiveStringCollation() throws IOException { + ClassPathResource migration = new ClassPathResource( + "db/migration/V13__make_existing_string_columns_case_sensitive.sql"); + + assertThat(migration.exists()).isTrue(); + + String sql = migration.getContentAsString(StandardCharsets.UTF_8); + assertThat(sql).contains("DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"); + assertThat(sql).contains("ALTER TABLE platform_source_message_inbox"); + assertThat(sql).contains( + "MODIFY COLUMN external_message_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"); + assertThat(sql).contains("ALTER TABLE platform_source_message_payload_duplicate"); + } + @Test void shouldPersistFailedInboxWhenExternalMessageIdIsMissing() { CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(