petstore-docs/架构决策-智能预约助手M0契约与数据模型-2026-08-02.md

306 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 架构决策:智能预约助手 M0 契约与数据模型
> 日期2026-08-02<br>
> 状态:**Accepted / Batch 2 implemented — petstore-backend `3c00065`**<br>
> Workstream`Core Booking Flow`<br>
> Track`Architecture`<br>
> Priority`P0`(独立试验流,不高于 RC6 真实门店基线)<br>
> Owner Agent`System Architect`<br>
> Paired QA`Core Flow QA`<br>
> Service Lock`docs:booking-agent-m0-contract+ontology`<br>
> Architecture Review Required`Yes — completed by this ADR`<br>
> Data Model Review Required`Yes — proposal and implementation diff completed`<br>
> Access / Privacy Review Required`Yes — development boundary accepted; live provider and production review pending`<br>
> RC Included`No`
**关联资产**
- [产品与技术方案](./宠小它智能预约助手-产品与技术方案-v0.1.md)
- [M0 编码任务 brief](./智能预约助手-M0编码任务brief-2026-08-02.md)
- [M0 执行队列](./智能预约助手-M0执行队列-2026-08-02.md)
- [`booking-intent-v1` JSON Schema](./contracts/booking-intent-v1.schema.json)
- [M0 OpenAPI 契约](./contracts/booking-agent-m0.openapi.yaml)
- [模型与语音供应商选型](./智能预约助手-模型与语音供应商选型-2026-08-02.md)
## 1. 决策结论
M0 冻结为 **「已登录 customer 在已选门店范围内,用文字或语音生成已验证预约草稿,再回填现有普通预约表单」**。
冻结边界:
1. 门店不是模型槽位。宠主必须先在 `CustAppointmentCreate` 选定门店,创建会话时只提交 `storeId`,服务端验证门店存在。
2. 模型只输出通过 `booking-intent-v1` 校验的非权威文本约束,不输出或决定业务 ID、价格、服务事实、号源或预约状态。
3. 宠物、服务、日期时间和号源全部由 Petstore 后端在 `current.userId + session.storeId` 范围内重新解析。
4. M0 没有 `/confirm` endpoint不调用 `AppointmentService.createBooking`,不修改 `Appointment` 表或状态机。
5. 「带入普通预约」只返回一次页面级回填数据;最终仍由现有 `/api/appointment/create` 提交。
6. 原始语音、当轮用户原文、模型请求/响应和完整对话不落库、不进业务事件、不进日志。
7. 功能开关默认关闭,不进入 `phase2-pilot-rc6`
## 2. 为什么先选门店
原方案允许助手在对话内收集门店,但会同时引入门店名称歧义、地理推荐、跨店服务匹配和无门店 `BusinessEvent` 等问题。当前产品仍是单店 SaaS 试点,不需要把门店发现变成 Agent 能力。
M0 的次级入口仍位于 `CustAppointmentCreate`,行为调整为:
1. 未登录:先登录并返回原预约页;
2. 未选门店:先使用现有门店选择控件;
3. 已选有效门店:创建智能会话;
4. 会话内只解析该门店的服务与容量。
这样可以让每个 session 和漏斗事件都有明确 `storeId`,并阻止模型扩大门店数据范围。
## 3. `booking-intent-v1` 冻结
模型输出以 [`contracts/booking-intent-v1.schema.json`](./contracts/booking-intent-v1.schema.json) 为唯一机器契约。
### 3.1 字段语义
| 字段 | 语义 | 是否业务权威 |
|---|---|---|
| `schemaVersion` | 固定 `booking-intent-v1` | 是,仅契约版本 |
| `intent` | `book / modify / end / fallback` | 否,状态机重新判定 |
| `petQuery` | 宠物展示名搜索词 | 否 |
| `serviceQuery` | 服务名称/同义表达搜索词 | 否 |
| `dateExpression` | 用户日期表达 | 否 |
| `timeWindow` | `HH:mm` 起止偏好 | 否 |
| `remark` | 用户希望带入预约的短备注 | 否,可编辑且限 200 字 |
| `clearFields` | 用户明确要求清空的草稿字段 | 否,服务端只允许白名单字段 |
| `ambiguities` | 模型观察到的歧义类型 | 否,服务端重新确认 |
| `nextAction` | 下一步建议 | 否,编排器可忽略 |
### 3.2 强校验顺序
1. 响应体必须是单个 JSON object不接受 Markdown fence 或前后说明文字;
2. JSON Schema 校验通过,未知字段拒绝;
3. 文本 trim、控制字符过滤和长度校验
4. 输出出现任意 ID、价格、手机号、号源或预约状态字段时整个响应降级
5. 服务端时间解析、归属解析和号源查询成功后才更新 `BookingDraft`
6. 失败不尝试从残缺 JSON 猜字段,返回 `AGENT_UNAVAILABLE` 并保留旧草稿。
### 3.3 时间口径
- 时区固定 `Asia/Shanghai`
- 支持绝对日期、今天/明天/后天、本周 X/下周 X、精确时间、上午/下午、X 点以前/以后;
- 「过几天」「最近」「周末都行」等表达不得猜成绝对日期,必须返回日期选择项;
- `appointmentTime` 只有在现有容量服务返回真实 slot 后才能写入草稿;
- 对外时间字符串统一为 `yyyy-MM-dd'T'HH:mm:ss`,语义为 `Asia/Shanghai` 本地时间。
## 4. M0 会话状态机
```mermaid
stateDiagram-v2
[*] --> collecting: create session
collecting --> collecting: 信息仍不足
collecting --> proposing: 可搜索真实号源
proposing --> collecting: 修改条件或需要澄清
proposing --> confirmable: 选择真实 slot
confirmable --> collecting: 用户修改草稿
collecting --> fallback: 用户回填表单或 LLM 降级
proposing --> fallback: 用户回填表单或 LLM 降级
confirmable --> fallback: 带入普通预约
collecting --> cancelled: 用户结束
proposing --> cancelled: 用户结束
confirmable --> cancelled: 用户结束
collecting --> expired: TTL 到期
proposing --> expired: TTL 到期
confirmable --> expired: TTL 到期
```
不允许的状态:`submitting`、`booked`、`needs_reselection`。终态 `fallback / expired / cancelled` 不接受新消息或转写。
每次消息请求必须带客户端当前 `draftVersion`。服务端只在版本相等时更新,成功后加一;版本冲突返回 `DRAFT_VERSION_CONFLICT`,不得静默覆盖。
## 5. API 冻结
唯一契约是 [`contracts/booking-agent-m0.openapi.yaml`](./contracts/booking-agent-m0.openapi.yaml)。M0 只注册五个 endpoint
| Method | Endpoint | 结果 |
|---|---|---|
| `POST` | `/api/booking-agent/sessions` | 在已选 `storeId` 下创建 customer 会话 |
| `POST` | `/api/booking-agent/sessions/{sessionId}/messages` | 提交当轮已确认文字并刷新草稿 |
| `POST` | `/api/booking-agent/sessions/{sessionId}/transcriptions` | 转写短音频,文字返回输入框,不自动发送 |
| `POST` | `/api/booking-agent/sessions/{sessionId}/fallback` | 返回已验证的部分/完整草稿并结束会话 |
| `DELETE` | `/api/booking-agent/sessions/{sessionId}` | 结束本人会话 |
### 5.1 身份与资源隐藏
- 所有 endpoint 都必须经过 `AuthInterceptor`
- controller 再要求 `current.role == customer`boss/staff 返回 `FORBIDDEN`
- session 每次以 `sessionId + current.userId` 查询;不存在和他人 session 统一返回 `SESSION_NOT_FOUND`
- `storeId` 只在创建时接收并验证,后续从 session 读取;
- 音频转写 endpoint 必须挂在 session 下,客户端不得上传宠物/服务词表。
### 5.2 响应语义
沿用 `{ code, message?, bizCode?, data? }`。当前前端以 body `code` 判断业务结果;除 `AuthInterceptor` 的 HTTP 401 外M0 不借机重构全局 HTTP status 行为。
草稿中的 `storeId / petId / serviceTypeId / appointmentTime` 是经过后端重新解析的事实;客户端回填后,现有预约创建服务仍会重新执行最终权限、服务和容量校验。
### 5.3 业务码
| body code | bizCode | 语义 |
|---|---|---|
| 401 | `UNAUTHENTICATED` | session token 缺失或失效 |
| 403 | `FORBIDDEN` | 非 customer 调用 |
| 503 | `AGENT_DISABLED` | 功能开关关闭 |
| 404 | `STORE_NOT_FOUND` | 创建时目标门店无效 |
| 404 | `SESSION_NOT_FOUND` | session 不存在或不属于当前 customer |
| 410 | `SESSION_EXPIRED` | 会话 TTL 到期 |
| 409 | `SESSION_TERMINAL` | 会话已 fallback/cancelled |
| 409 | `DRAFT_VERSION_CONFLICT` | 客户端草稿版本过旧 |
| 400 | `INVALID_INPUT` | 空文本、超长或请求结构无效 |
| 400 | `INVALID_AUDIO` | 空音频、格式/MIME/文件头不匹配或时长非法 |
| 413 | `AUDIO_TOO_LARGE` | 超过 3 MB |
| 429 | `RATE_LIMITED` | customer 请求频率超限 |
| 503 | `AGENT_UNAVAILABLE` | LLM 超时、无效 JSON/schema 或外部失败 |
| 503 | `ASR_UNAVAILABLE` | ASR 超时或外部失败 |
宠物/服务歧义、无号源和未选时段是正常 `200` 会话响应,不用异常码表达。
## 6. 数据模型冻结
已实现迁移:`backend/db/migrations/20260802_create_booking_agent_session.sql`petstore-backend `3c00065`)。
### 6.1 `t_booking_agent_session`
| 字段 | 类型 | 规则 |
|---|---|---|
| `id` | BIGINT | 自增主键,只在后端和 `BusinessEvent.aggregate_id` 使用 |
| `session_id` | VARCHAR(36) | UUID唯一对客户端暴露 |
| `customer_user_id` | BIGINT | 必须是创建会话的 customer |
| `store_id` | BIGINT | M0 必填;创建时验证存在,后续不接受覆盖 |
| `status` | VARCHAR(24) | 仅六个 M0 状态 |
| `draft_json` | TEXT | 最小已验证草稿,不存当轮原文/模型响应 |
| `draft_version` | INT | 初始 0每次成功草稿更新加一 |
| `entry_source` | VARCHAR(32) | M0 固定 `appointment_create` |
| `input_modality` | VARCHAR(16) | `text / voice / mixed`,首次成功消息后派生 |
| `expires_at` | DATETIME | 创建时固定 `now + 30min`M0 不滑动续期 |
| `create_time / update_time` | DATETIME | 服务端时间 |
索引:
- `UNIQUE uk_booking_agent_session_public_id (session_id)`
- `INDEX idx_booking_agent_customer_status_expire (customer_user_id, status, expires_at)`
- `INDEX idx_booking_agent_status_expire (status, expires_at)`
不增加:
- `appointment_id`
- `t_booking_agent_turn`
- 原始语音/转写/提示词/模型响应列;
- `deleted`。会话是短期对象,使用明确终态并按 TTL 物理清理。
### 6.2 TTL 与清理
1. 会话固定 30 分钟有效,不因消息滑动延长;
2. endpoint 发现过期时返回 `SESSION_EXPIRED`,并以幂等方式标记 `expired`
3. 每小时清理任务将到期活动会话标记 `expired`
4. `expires_at` 早于当前时间 24 小时的 session 物理删除;
5. 清理失败记录数量和错误类别,不记录 `draft_json`
6. `BusinessEvent` 是独立低敏事实,不随短期 session 删除。
## 7. 业务事件冻结
M0 只新增三个首次漏斗事实:
| 事件 | 触发 | 幂等键 | metadata 白名单 |
|---|---|---|---|
| `booking_agent_started` | session 创建成功 | `booking_agent_started:{sessionDbId}` | `entrySource` |
| `booking_agent_draft_ready` | session 首次进入 `confirmable` | `booking_agent_draft_ready:{sessionDbId}` | `inputModality` |
| `booking_agent_fallback` | 用户回填表单或 LLM 降级 | `booking_agent_fallback:{sessionDbId}` | `reason=user/llm_unavailable` |
事件使用:
- `storeId=session.storeId`
- `aggregateType=booking_agent_session`
- `aggregateId=session.id`
- `actorUserId=session.customerUserId`、`actorRole=customer`、`source=customer`
- 已有 `StoreCustomer` 时可关联,不能只为启动助手创建客户主档。
禁止把输入原文、转写文本、备注、宠物名、服务名、供应商 request ID 或错误原文写入 metadata。
M0 不写 `booking_agent_confirmed`,也不修改现有 `appointment_created` metadata。精确联结「助手草稿 -> 表单创建」需要另行设计服务端一次性交接机制。
## 8. 外部调用与隐私门禁
### 8.1 语音
- 单段不超过 60 秒、3 MB客户端预检服务端按字节、文件头、MIME 和解析时长复核;
- ASR 使用 `qwen3-asr-flash-2026-02-10` Base64 Data URL不中转 OSS
- 上下文只包含当前门店服务名和当前 customer 的宠物展示名;
- 返回转写后立即释放音频字节,不缓存、不写文件、不返回供应商 request ID
- 转写必须由用户编辑/确认后才能调用 messages。
### 8.2 LLM
- 使用固定 `qwen-plus-2025-12-01`、非思考、非流式、无工具、无联网;
- LLM 超时 3 秒ASR 超时 5 秒;不做多次自动重试;
- 请求只包含当次文字、最小草稿和枚举说明不包含手机号、token、客户时间线或其他门店数据
- API Key、Workspace ID 和 Authorization header 不进 Git、测试 fixture、聊天、截图或日志。
### 8.3 频率门禁
M0 默认每个 customer
- 10 分钟内最多创建 5 个 session
- 10 分钟内最多提交 30 条 message
- 10 分钟内最多转写 10 段音频。
超限返回 `RATE_LIMITED`,普通预约继续可用。当前单实例可先使用进程内限流;进入多实例前必须切换共享限流状态。
## 9. Review 结论
### Architecture Review
**通过,可进入实现。** 条件是严格保留五个 endpoint、无 `/confirm`、门店先选、普通表单独立可用、feature flag 默认关闭。
### Data Model Review
**通过提案,可由 Backend Core 实现迁移。** 相比原 brief 的修订:
- 使用 `id BIGINT` 内部主键 + `session_id UUID` 外部唯一键;
- `store_id` 从可空改为 M0 必填;
- 去掉 `deleted``appointment_id`
- 增加固定 TTL、物理清理和 `draft_version` 并发规则。
**实现 diff 复核通过。** `BookingAgentSession` JPA、迁移与 mapper 保持上述冻结字段和三个索引;六态与 `input_modality` 有数据库 `CHECK`owner 查询持悲观锁,未增加 `appointment_id`、`deleted`、turn 表或原文列。迁移测试、版本/TTL/清理测试均已落地。
### Access / Privacy Review
**开发边界通过,真实供应商 smoke 与生产开启未通过。** 不需要真实密钥即可实现接口、stub、状态机和测试真实调用前仍需完成供应商数据处理确认、小程序隐私告知、费用告警和已知情语音样本评测。
## 10. 后果与风险
正向结果:
- M0 不增加第二条预约写路径,模型失败不会污染 `Appointment`
- session 可短期恢复结构化草稿,又不形成对话原文数据库;
- OpenAPI、JSON Schema 和本体可以直接驱动测试与 review
- 后续 M1 必须另立 ADR 才能新增 confirm/appointment 关联。
已知限制:
- 门店必须先选,无法用一句话同时完成「找店 + 预约」;
- M0 不精确统计回填后最终创建转化;
- 进程内限流只适用于当前单实例;
- 供应商效果仍需真实语料验证,当前推荐不是生产准入结论。
## 11. 实现门禁
编码 PR 必须同时满足:
- 引用本 ADR、OpenAPI、JSON Schema 和对应 `ontologyRefs`
- provider 单测只访问本地 HTTP stub
- 后端测试证明整个 M0 不调用 `AppointmentMapper.save`
- 未配置密钥或 feature flag 关闭时应用正常启动、普通预约正常;
- 本体从 `documented` 更新为 `anchored` 只能与代码和测试同一批完成;
- 真实供应商 smoke、生产配置和 RC 纳入必须重新授权。
## 12. Batch 2 实现证据
- Backend`3c00065 feat: add booking agent session orchestration`
- 五个 endpoint、JPA 会话、迁移、owner/store 数据边界、版本与 TTL 已实现;
- 本体 `BookingAgentSession`、五个动作、三个事件、五条规则和十条关系已从 `documented` 更新为 `anchored`
- 后端全量测试 292 项通过,本体端点 73/73、JPA 实体 17/17无漂移
- 功能开关仍默认关闭,真实供应商 smoke、Batch 3 前端和生产开启仍未完成。