Files
wecom_it_smart_desk/docs/02-技术文档/技术方案-REQ-坐席-010-AI回复三态开关.md
T

697 lines
44 KiB
Markdown
Raw Normal View History

# 技术方案 - AI 回复三态开关(REQ-坐席-010
> **REQ编号**: REQ-坐席-010
> **版本**: v1.0
> **优先级**: P0(含现状行为缺口修复)
> **日期**: 2026-08-04
> **架构师**: 高见远(Gao
> **上游 PRD**: `docs/01-产品文档/04-坐席工作台/PRD-REQ-坐席-010-AI回复三态开关.md`
> **代码事实核验**: 已逐文件 grep/read 确认(见附录 A「代码事实核验表」)
---
## 一、实现方案(Implementation Approach
### 1.1 核心难点
| # | 难点 | 解法 |
|---|------|------|
| D1 | AI 自动回复存在**两条完全独立路径**,历史上门控口径不一致(路径 A 有 status 门控、路径 B 零门控) | 抽取**单一门控判定函数** `ai_reply_gate.py`,两条路径共用同一套判定,杜绝口径漂移 |
| D2 | 路径 B 是 `asyncio.create_task` 后台任务,任务内部再判断会**白白占用 DB session + Dify 配额** | 门控前置到**调用点**(`h5.py` / `ws.py`),`create_task` 之前拦截 |
| D3 | `employee_and_agent` 的"推坐席"在两条路径上机制完全不同:路径 A **根本没有**坐席 WS 广播(只 `wecom send`),路径 B **默认就广播**(需反向收紧) | 路径 A **补齐**广播;路径 B **加条件**收紧。两侧都收敛到同一 helper `should_push_to_agent()` |
| D4 | 路径 B 的坐席广播点分散在 `h5_ai_task.py` 的 4 个函数中,逐点改易漏 | 统一用 helper 包装,任务清单**逐行号列出全部 4 处**(附录 B) |
| D5 | 字段需与 `status` 状态机严格正交,且存量数据要有默认值 | 新增独立 `String(20)` 字段(**不用 DB 原生 ENUM**,与既有 `status` 写法一致,兼容 SQLite/PG),Alembic 迁移带 `server_default` |
### 1.2 框架选型(全部沿用既有,无新增)
| 层 | 技术 | 说明 |
|----|------|------|
| 后端 Web | FastAPI + Pydantic v2 | 沿用;新端点复用 `success_response` / `AppException` / `@require_permission` |
| ORM | SQLAlchemy 2.0 (`Mapped` / `mapped_column`) | 沿用;新字段写法对齐既有 `status` 字段 |
| 迁移 | **Alembic**(已确认:`src/backend/alembic/``alembic.ini`,当前 head = `057_troubleshooting_templates` | 新增 revision `058_add_ai_reply_mode` |
| 实时通道 | 进程内单例 `app.services.ws_manager.manager` | 沿用 `broadcast()`(全坐席)/ `broadcast_to_employees()`(指定员工) |
| 前端 | Vue3 `<script setup>` + Element Plus + Vite + Pinia | 沿用;`el-popover` + `el-radio-group`,复用 `.tb-btn` / `.tb-sep` 既有样式 |
### 1.3 架构决策记录(ADR,已拍板)
| # | 决策 | 理由 |
|---|------|------|
| ADR-1 | 字段名 `ai_reply_mode`,类型 `String(20)` 而非 SQLAlchemy `Enum` | 与既有 `status`/`emotion_state` 写法一致;避免 PG 原生 enum 类型带来的迁移/回滚复杂度;SQLite 本地开发兼容 |
| ADR-2 | 枚举值以**模块常量**集中定义(`app/constants/ai_reply_mode.py`),前后端各持一份,不引入代码生成 | 项目现无 schema 共享机制,硬编码字符串分散风险高,集中常量 + 前端 `as const` 是最小成本方案 |
| ADR-3 | 端点落在 **`app/api/conversations.py`**,路径 `PUT /api/conversations/{conversation_id}/ai-reply-mode` | 该文件已聚合全部会话级坐席操作(assign/pin/todo/transfer/grab/tags),语义归位;`app/api/agent.py` 在本仓库**不存在**(只有 `agents.py`,是坐席账号域),放 h5.py 则是员工域,均不合适 |
| ADR-4 | 门控判定抽成独立模块 `app/services/ai_reply_gate.py`(纯函数,无 IO) | 两条路径 + 4 个广播点共用,可单测,避免逻辑漂移 |
| ADR-5 | 路径 B 门控条件采用**白名单** `status in ("ai_handling","queued")`,而非黑名单 `status != "serving"` | 白名单同时挡掉 `pending_close` / `resolved`(黑名单会漏),语义更安全。已确认 H5 新建会话 status=`ai_handling``h5.py:876`),不误伤正常流程 |
| ADR-6 | 在途任务**不中断**PRD P1-4 | `asyncio.create_task` 已提交的任务不做 cancel,避免半截消息;门控只作用于「新触发」 |
| ADR-7 | 新增 WS 事件 `ai_reply_mode_changed` 而非复用 `conversation_updated` | `conversation_updated` 在前端会触发会话列表刷新(重逻辑),模式切换是轻量字段同步,独立事件更精准、可单独灰度 |
---
## 二、文件列表(相对路径 · 标注 [新增]/[修改])
### 2.1 后端
| # | 文件 | 标记 | 改动摘要 |
|---|------|------|---------|
| B1 | `src/backend/app/constants/ai_reply_mode.py` | **[新增]** | 三态字符串常量、默认值、合法集合、路径 B status 白名单 |
| B2 | `src/backend/app/models/conversation.py` | [修改] | `Conversation` 新增 `ai_reply_mode: Mapped[str]`String(20), default `employee_only`, NOT NULL |
| B3 | `src/backend/alembic/versions/058_add_ai_reply_mode.py` | **[新增]** | `add_column` + `server_default='employee_only'` + 回填 + downgrade |
| B4 | `src/backend/app/schemas/conversation.py` | [修改] | 新增 `AiReplyModeUpdate`(含 validator)、`AiReplyModeResponse``ConversationResponse``ai_reply_mode: str` |
| B5 | `src/backend/app/services/ai_reply_gate.py` | **[新增]** | `can_ai_auto_reply_path_a()` / `can_ai_auto_reply_path_b()` / `should_push_to_agent()` 三个纯函数 |
| B6 | `src/backend/app/api/conversations.py` | [修改] | 新增 `PUT /conversations/{id}/ai-reply-mode` 端点 + WS 广播 `ai_reply_mode_changed` |
| B7 | `src/backend/app/services/message_router.py` | [修改] | **L176** 门控加 `ai_reply_mode != off`**`_try_ai_reply`(L458)** 末尾在 `employee_and_agent` 时补坐席 WS 广播 |
| B8 | `src/backend/app/api/h5.py` | [修改] | **L939** `create_task` 前加路径 B 门控 |
| B9 | `src/backend/app/api/ws.py` | [修改] | **L472** `create_task` 前加路径 B 门控(option_select 反馈) |
| B10 | `src/backend/app/tasks/h5_ai_task.py` | [修改] | 4 处坐席广播点(L482 / L749 / L891 / L1603)改为 `should_push_to_agent()` 条件广播 |
### 2.2 前端(坐席工作台 `src/frontend-agent`
| # | 文件 | 标记 | 改动摘要 |
|---|------|------|---------|
| F1 | `src/frontend-agent/src/types/ai-reply-mode.ts` | **[新增]** | `AiReplyMode` 联合类型、三态选项元数据(label/desc/icon 色) |
| F2 | `src/frontend-agent/src/api/conversation.ts` | [修改] | `Conversation` 接口增 `ai_reply_mode`;新增 `updateAiReplyMode()` |
| F3 | `src/frontend-agent/src/components/chat/AiReplyModeSwitch.vue` | **[新增]** | 🤖 按钮 + `el-popover` + `el-radio-group` 三态组件(乐观更新 + 失败回滚) |
| F4 | `src/frontend-agent/src/components/chat/ReplyBox.vue` | [修改] | 工具栏最左侧挂载 `<AiReplyModeSwitch>` + `.tb-sep` |
| F5 | `src/frontend-agent/src/stores/conversation.ts` | [修改] | 新增 `setAiReplyMode(convId, mode)` 本地写入 + `handleAiReplyModeChanged()` |
| F6 | `src/frontend-agent/src/composables/useWebSocket.ts` | [修改] | `handleMessage` switch 增 `case 'ai_reply_mode_changed'` |
### 2.3 测试
| # | 文件 | 标记 | 改动摘要 |
|---|------|------|---------|
| T1 | `src/backend/tests/test_ai_reply_gate.py` | **[新增]** | 门控纯函数真值表单测(3 mode × 5 status |
| T2 | `src/backend/tests/test_ai_reply_mode_api.py` | **[新增]** | 端点 200/非法值 400/不存在 404/持久化断言 |
---
## 三、数据结构与接口
### 3.1 数据模型变更(ER 片段)
```
conversations
├─ id VARCHAR(36) PK
├─ status VARCHAR(20) NOT NULL DEFAULT 'queued' ← 既有,本次不动
├─ ai_reply_mode VARCHAR(20) NOT NULL DEFAULT 'employee_only' ← 🆕 本次新增(与 status 正交)
├─ assigned_agent_id VARCHAR(64) NULL
└─ ...(其余字段不变)
```
| 字段 | 类型 | 约束 | 默认 | 说明 |
|------|------|------|------|------|
| `ai_reply_mode` | `VARCHAR(20)` | NOT NULL | `'employee_only'` | 取值 `employee_only` / `employee_and_agent` / `off` |
> **不加索引**:该字段只在「已知 conversation_id 后单行读取」场景使用,无范围查询,加索引纯负收益。
### 3.2 类图(Mermaid classDiagram
```mermaid
classDiagram
class AiReplyMode {
<<constants>>
+str EMPLOYEE_ONLY$ = "employee_only"
+str EMPLOYEE_AND_AGENT$ = "employee_and_agent"
+str OFF$ = "off"
+str DEFAULT$ = "employee_only"
+frozenset ALL$
+frozenset PATH_B_ALLOWED_STATUS$ = {ai_handling, queued}
}
class Conversation {
+str id
+str employee_id
+str status
+str ai_reply_mode
+str assigned_agent_id
+dict tags
+int ai_substantive_reply_count
+__repr__() str
}
class AiReplyGate {
<<module: app.services.ai_reply_gate>>
+can_ai_auto_reply_path_a(conversation) bool
+can_ai_auto_reply_path_b(conversation) bool
+should_push_to_agent(conversation) bool
}
class AiReplyModeUpdate {
<<pydantic BaseModel>>
+str mode
+validate_mode(v) str
}
class AiReplyModeResponse {
<<pydantic BaseModel>>
+str conversation_id
+str ai_reply_mode
}
class ConversationResponse {
<<pydantic BaseModel>>
+str id
+str status
+str ai_reply_mode
}
class MessageRouter {
<<app.services.message_router>>
-AIHandler ai_handler
-WecomService wecom_service
+handle_employee_message(...) Conversation
-_try_ai_reply(conversation, content, from_user_id) bool
}
class H5AiTask {
<<app.tasks.h5_ai_task>>
+process_h5_ai_reply(...) None
-_persist_and_push(...) None
-_persist_and_push_structured(...) None
-_handle_byod_query(...) None
-_step_call_dify(...) AIReplyResult
}
class ConnectionManager {
<<app.services.ws_manager singleton>>
+Dict~str,WebSocket~ active_connections
+Dict~str,WebSocket~ employee_connections
+broadcast(data) None
+send_to_agent(agent_id, data) None
+broadcast_to_employees(ids, data) None
}
class ConversationsAPI {
<<app.api.conversations>>
+update_ai_reply_mode(conversation_id, body, db) dict
}
AiReplyGate ..> AiReplyMode : 读常量
AiReplyGate ..> Conversation : 只读判定
AiReplyModeUpdate ..> AiReplyMode : 值校验
ConversationsAPI --> Conversation : 写 ai_reply_mode
ConversationsAPI --> AiReplyModeUpdate : 请求体
ConversationsAPI --> AiReplyModeResponse : 响应体
ConversationsAPI --> ConnectionManager : broadcast(ai_reply_mode_changed)
MessageRouter ..> AiReplyGate : 路径A门控 + 推坐席判定
MessageRouter --> ConnectionManager : 🆕 employee_and_agent 时广播
H5AiTask ..> AiReplyGate : 推坐席判定
H5AiTask --> ConnectionManager : 条件广播
ConversationResponse ..> Conversation : from_attributes
```
### 3.3 门控真值表(**唯一权威口径**,实现必须与此表逐格一致)
**路径 A(企微 App`message_router.py:176`)— 是否触发 AI 自动回复**
| status \ mode | `employee_only` | `employee_and_agent` | `off` |
|---|---|---|---|
| `ai_handling` | ✅ 触发(仅推员工) | ✅ 触发(推员工 **+ 广播坐席**| ❌ |
| `queued` / `serving` / `pending_close` / `resolved` | ❌(既有行为,不变) | ❌ | ❌ |
**路径 BH5/WS`h5.py:939` / `ws.py:472`)— 是否 `create_task`**
| status \ mode | `employee_only` | `employee_and_agent` | `off` |
|---|---|---|---|
| `ai_handling` | ✅ 触发(AI 回复**仅推员工**,不广播坐席) | ✅ 触发(推员工 **+ 广播坐席**| ❌ |
| `queued` | ✅ 触发(同上) | ✅ 触发 | ❌ |
| `serving` | ❌ **🔴 本次修复的缺口**(原为 ✅ 误触发) | ❌ | ❌ |
| `pending_close` / `resolved` | ❌ | ❌ | ❌ |
> ⚠️ **行为变更提示(供 QA 重点回归)**:路径 B 在 `employee_only` 下**不再向坐席广播 AI 回复**(原为无条件广播)。这是 PRD 三态语义的必然结果——"仅回复员工"意味着员工-AI 对话对坐席不可见。
### 3.4 门控函数签名(`app/services/ai_reply_gate.py`
```python
def can_ai_auto_reply_path_a(conversation: Conversation) -> bool:
"""路径A(企微App)是否允许 AI 自动回复。
条件:status == 'ai_handling' AND ai_reply_mode != 'off'
"""
def can_ai_auto_reply_path_b(conversation: Conversation) -> bool:
"""路径BH5/WS)是否允许发起 process_h5_ai_reply 后台任务。
条件:status in {'ai_handling','queued'} AND ai_reply_mode != 'off'
必须在 asyncio.create_task 之前调用(避免无谓占用 DB session / Dify 配额)。
"""
def should_push_to_agent(conversation: Conversation) -> bool:
"""AI 自动回复是否需要广播给坐席端。
条件:ai_reply_mode == 'employee_and_agent'
"""
```
> **健壮性要求**:三个函数必须用 `getattr(conversation, "ai_reply_mode", None) or DEFAULT` 兜底读取,防止灰度期未迁移实例 / mock 对象缺字段导致 `AttributeError` 打挂主链路。
### 3.4.1 「推坐席」语义(按用户 2026-08-04 15:47 最终口径修正)
> ⚠️ **本节为 2026-08-04 用户拍板澄清后的最终口径**,原 §3.4 单函数描述保留向后兼容,新实现按本节**双函数**结构落地。
**B 口径核心语义**(用户拍板 2026-08-04 15:47):
> a 态(employee_only= AI 只对员工消息/选择作进一步回复;坐席端**仍按现状能看到 AI 回复员工的消息**。
> 即:a 维持现状镜像;b 在 a 基础上**额外**推专属;off 全关。
按此口径,「推坐席」拆分为两个**正交**判定函数(而非合并的 `should_push_to_agent`):
| 函数 | 语义 | 取值条件 | 关系 |
|------|------|---------|------|
| `agent_mirror_enabled(mode)` | 是否保留**坐席侧镜像广播**(既有行为,new_message / conversation_updated / ai_thinking 等) | `mode != "off"` | a ✓,b ✓,off ✗ |
| `agent_dedicated_message(mode)` | 是否**额外**推送坐席专属「AI 协助」消息 | `mode == "employee_and_agent"` | a ✗,b ✓,off ✗ |
**推坐席行为表**(最终):
| mode \ 推坐席行为 | 镜像广播(既有) | 坐席专属消息(新增) |
|------------------|----------------|------------------|
| `employee_only`(默认) | ✅ 保留现状 | ✗ 不推 |
| `employee_and_agent` | ✅ 保留现状 + 额外推专属 | ✅ 推 |
| `off` | ✗ 关闭 | ✗ 不推 |
> **行为变更提示(供 QA 重点回归)**:
> 1. 路径 B 在 `employee_only` 下**不再**向坐席镜像广播 AI 回复(原为无条件广播)。这是三态语义的必然结果——"仅回复员工"意味着员工-AI 对话对坐席不可见。
> 2. `employee_and_agent` 在「镜像 + 专属」两条路径上**同时**生效:镜像广播触发 `new_message`/`conversation_updated`(与 a 同链路),专属推送触发 `ai_reply_for_agent`(带 `【AI 协助】` 前缀)。
**当前实现已与本口径一致**(详见 §3.4.2):
```python
# app/services/ai_reply_gate.py
def agent_mirror_enabled(mode: Optional[str]) -> bool:
return resolve_mode(mode) != AI_REPLY_MODE_OFF
def agent_dedicated_message(mode: Optional[str]) -> bool:
return resolve_mode(mode) == AI_REPLY_MODE_EMPLOYEE_AND_AGENT
```
**调用点(实际代码)**
- `src/backend/app/tasks/h5_ai_task.py` 4 处坐席广播点(L540 / L817 / L970 / L1684)→ `if agent_mirror_enabled(_conv_mode):` 守卫
- `src/backend/app/tasks/h5_ai_task.py` 2 处专属推送(L567 / L855)→ `_push_agent_dedicated_message(...)`(函数内已 `if not agent_dedicated_message(conversation_mode(conversation)): return` 兜底)
- `src/backend/app/services/message_router.py` 路径 A L580 → `if agent_dedicated_message(conversation_mode(conversation)):` 守卫
### 3.4.2 与 §3.4 单函数版本的差异说明
| 维度 | 原 §3.4 单函数 | 本方案最终实现 |
|------|--------------|--------------|
| 函数数量 | 1 个 `should_push_to_agent()` | 2 个:`agent_mirror_enabled()` + `agent_dedicated_message()` |
| 入参 | `conversation` 对象 | 原始 `mode` 字符串(纯函数) |
| 语义 | 「是否推坐席」(合并) | 镜像 / 专属 两条独立通道 |
| 与 B 口径一致性 | 模糊 | ✅ 严格对齐 |
> 历史文档保留 §3.4 仅作 ADR 记录;**所有新代码一律按 §3.4.1 双函数实现**。
### 3.5 设置 API 契约
```
PUT /api/conversations/{conversation_id}/ai-reply-mode
```
| 项 | 值 |
|---|---|
| 权限 | `@require_permission("conversation", "update", "own")`(对齐 pin/todo |
| Content-Type | `application/json` |
| 幂等 | 是(同值重复提交返回相同结果) |
| 并发 | 最后写入者胜(last-write-wins,无版本号/乐观锁) |
**请求体** `AiReplyModeUpdate`
```json
{ "mode": "employee_and_agent" }
```
**成功响应** `200`(统一 `success_response` 包装)
```json
{
"code": 0,
"message": "success",
"data": { "conversation_id": "b3f1...", "ai_reply_mode": "employee_and_agent" }
}
```
**错误响应**
| code | HTTP | 触发条件 | message |
|------|------|---------|---------|
| `1001` | 200(业务码) | `mode` 不在合法集合 | `AI回复模式非法,仅支持 employee_only/employee_and_agent/off` |
| `1002` | 200(业务码) | conversation 不存在 | `会话不存在` |
> 沿用项目既有 `AppException(code, msg)` 约定(业务码在 bodyHTTP 保持 200),**不要**自创 HTTP 4xx。
### 3.6 WS 消息新增类型
| 事件 | 方向 | 触发点 | payload |
|------|------|--------|---------|
| `ai_reply_mode_changed` | 服务端 → **全体坐席** (`ws_manager.broadcast`) | 设置 API 写库成功后 | `{"type":"ai_reply_mode_changed","data":{"conversation_id":"...","ai_reply_mode":"off"}}` |
> 用 `broadcast()` 而非 `send_to_agent()`:会话可能有协作坐席(`collaborating_agent_ids`+ 参与者,逐个定向推容易漏;前端按 `conversation_id` 自行过滤,成本可忽略。
---
## 四、程序调用流程(时序图)
### 4.1 时序图①:坐席切换 mode 的完整调用链
```mermaid
sequenceDiagram
autonumber
actor Agent as 坐席
participant SW as AiReplyModeSwitch.vue
participant Store as stores/conversation.ts
participant Api as api/conversation.ts
participant EP as conversations.py<br/>PUT /ai-reply-mode
participant Schema as AiReplyModeUpdate
participant DB as PostgreSQL<br/>conversations
participant WS as ws_manager
participant Other as 其他坐席端<br/>useWebSocket.ts
Agent->>SW: 点击 🤖 → el-popover 展开
SW->>Store: 读 currentConversation.ai_reply_mode
Store-->>SW: "employee_only"radio 选中态)
Agent->>SW: 选中「关闭自动回复」
Note over SW,Store: P1-1 乐观更新
SW->>Store: setAiReplyMode(convId, "off")
Store-->>SW: UI 立即反映(🤖 置灰)
SW->>Api: updateAiReplyMode(convId, "off")
Api->>EP: PUT /api/conversations/{id}/ai-reply-mode<br/>{"mode":"off"}
EP->>Schema: AiReplyModeUpdate(mode="off")
alt mode 非法
Schema-->>EP: ValidationError
EP-->>Api: AppException(1001, "AI回复模式非法…")
Api-->>SW: reject
SW->>Store: 回滚为原值 "employee_only"
SW->>Agent: ElMessage.error + popover 保持打开
else mode 合法
EP->>DB: SELECT * FROM conversations WHERE id=:id
alt 会话不存在
DB-->>EP: None
EP-->>Api: AppException(1002, "会话不存在")
Api-->>SW: reject → 回滚 + 报错
else 会话存在
EP->>DB: UPDATE ai_reply_mode='off', updated_at=now()
DB-->>EP: committed
EP->>WS: broadcast(ai_reply_mode_changed)
WS-->>Other: {"type":"ai_reply_mode_changed",<br/>"data":{conversation_id, ai_reply_mode}}
Other->>Other: store.handleAiReplyModeChanged()<br/>同步本地态
EP-->>Api: {code:0, data:{conversation_id, ai_reply_mode:"off"}}
Api-->>SW: resolve
SW->>Agent: ElMessage.success("已切换为:关闭自动回复")<br/>+ 关闭 popover
end
end
Note over Agent,DB: 切到别的会话再切回 → 从 ConversationResponse.ai_reply_mode<br/>读回该会话自己的值(P0-6 单会话持久化)
```
### 4.2 时序图②:路径 B 员工发消息 —— 三态 × status 门控与推送分支
```mermaid
sequenceDiagram
autonumber
actor Emp as 员工(H5)
participant H5 as api/h5.py<br/>send_message
participant DB as conversations / messages
participant Gate as ai_reply_gate
participant Task as tasks/h5_ai_task.py<br/>process_h5_ai_reply
participant Dify as Dify
participant WS as ws_manager
actor AgentUI as 坐席工作台
Emp->>H5: POST /api/h5/.../messages {content}
H5->>DB: 查/建 conversation(新建 status=ai_handling
H5->>DB: INSERT message(sender_type=employee)
H5->>WS: broadcast(new_message, sender=employee)
WS-->>AgentUI: 员工消息(🔸不受本开关影响,始终可见)
H5->>DB: COMMIT(保证后台任务读得到)
rect rgb(255, 244, 230)
Note over H5,Gate: 🔑 门控必须在 asyncio.create_task **之前**h5.py:939 / ws.py:472
H5->>Gate: can_ai_auto_reply_path_b(conversation)
Gate->>Gate: status in {ai_handling, queued}?<br/>AND ai_reply_mode != "off"?
end
alt ❌ mode == "off"
Gate-->>H5: False
H5->>H5: logger.info("AI自动回复已关闭,跳过")
H5-->>Emp: 200 {user_message, ai_reply:null}
Note over Task: 后台任务从未创建 → 零 DB session / 零 Dify 调用
else ❌ status == "serving"(🔴 本次修复的缺口)
Gate-->>H5: False
H5->>H5: logger.info("坐席已接管,跳过AI自动回复")
H5-->>Emp: 200 {user_message, ai_reply:null}
Note over AgentUI: 坐席独占对话,AI 不再抢话(PRD G2 / US2
else ✅ 允许触发
Gate-->>H5: True
H5->>Task: asyncio.create_task(process_h5_ai_reply(...))
H5-->>Emp: 200(立即返回,AI 回复走 WS 异步推)
Task->>DB: _step_load_conversation
Task->>Dify: _step_call_dify
Note over Task,WS: ai_thinking 坐席广播(h5_ai_task.py:1603<br/>同样受 should_push_to_agent 门控
Task->>Gate: should_push_to_agent(conversation)
alt mode == employee_and_agent
Task->>WS: broadcast(ai_thinking)
WS-->>AgentUI: AI 思考中…
end
Dify-->>Task: AIReplyResult
Task->>DB: _persist_and_push*INSERT message(sender_type=ai)
Task->>WS: broadcast_to_employees([emp], ai_reply)
WS-->>Emp: 💬 AI 回复(三态下只要触发就必推员工)
rect rgb(232, 245, 233)
Note over Task,Gate: 坐席广播门控(4 处:L482 / L749 / L891 / L1603
Task->>Gate: should_push_to_agent(conversation)
end
alt mode == "employee_and_agent"
Gate-->>Task: True
Task->>WS: broadcast(new_message, sender_type=ai)
Task->>WS: broadcast(conversation_updated)
WS-->>AgentUI: 👁️ 坐席同步看到 AI 回复内容(P0-7 / US3
else mode == "employee_only"(默认)
Gate-->>Task: False
Note over Task,AgentUI: ⚠️ 行为变更:不再广播坐席<br/>员工-AI 会话对坐席不可见
end
end
Note over H5,Task: ADR-6:切 off / 转 serving **不 cancel** 已提交的在途 task<br/>让其自然结束(PRD P1-4,避免半截消息)
```
---
## 五、任务分解(Task List
> **粒度**:文件级,工程师可照单执行。**顺序**:T01 → T02/T03/T04(可并行)→ T05。
### T01 · 数据层:字段 + 常量 + 迁移 + Schema P0 依赖:无
| 文件 | 标记 | 改动点 |
|------|------|--------|
| `src/backend/app/constants/ai_reply_mode.py` | [新增] | 定义 `EMPLOYEE_ONLY="employee_only"` / `EMPLOYEE_AND_AGENT="employee_and_agent"` / `OFF="off"` / `DEFAULT=EMPLOYEE_ONLY` / `ALL=frozenset({...})` / `PATH_B_ALLOWED_STATUS=frozenset({"ai_handling","queued"})`。若 `app/constants/` 目录不存在需一并建 `__init__.py` |
| `src/backend/app/models/conversation.py` | [修改] | 在 `emotion_state`L220-225)之后插入 `ai_reply_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="employee_only", server_default="employee_only", comment="AI自动回复模式: employee_only/employee_and_agent/off(与status正交)")`;同步补类docstring 的 Attributes 说明。**不加索引**ADR |
| `src/backend/alembic/versions/058_add_ai_reply_mode.py` | [新增] | `revision='058_add_ai_reply_mode'`, `down_revision='057_troubleshooting_templates'``upgrade()`:用 `sa.inspect(bind)` 检查列是否已存在(对齐 057 的幂等写法)→ `op.add_column('conversations', sa.Column('ai_reply_mode', sa.String(20), nullable=False, server_default='employee_only'))` → 显式 `op.execute("UPDATE conversations SET ai_reply_mode='employee_only' WHERE ai_reply_mode IS NULL")` 兜底回填。`downgrade()``op.drop_column` |
| `src/backend/app/schemas/conversation.py` | [修改] | ① 新增 `AiReplyModeUpdate(BaseModel)``mode: str = Field(..., description=...)` + `@field_validator("mode")` 校验属于 `ALL`,非法抛 `ValueError`(仿 L122 `ConversationStatusUpdate.validate_status` 写法);② 新增 `AiReplyModeResponse(BaseModel)``conversation_id: str` / `ai_reply_mode: str`;③ `ConversationResponse`L240)增字段 `ai_reply_mode: str = "employee_only"`(**必须加,否则前端拿不到当前态**) |
**验收**`alembic upgrade head` 成功;存量行 `ai_reply_mode='employee_only'``GET /api/conversations` 返回体含该字段。
---
### T02 · 门控内核 + 设置 API + WS 事件 P0 依赖:T01
| 文件 | 标记 | 改动点 |
|------|------|--------|
| `src/backend/app/services/ai_reply_gate.py` | [新增] | 实现 §3.4 三个纯函数,逐条对齐 §3.3 真值表。**必须**用 `getattr(conv,"ai_reply_mode",None) or DEFAULT` 兜底。无 IO、无 async,便于单测 |
| `src/backend/app/api/conversations.py` | [修改] | 新增 `PUT /conversations/{conversation_id}/ai-reply-mode`,挂 `@require_permission("conversation","update","own")`(照抄 L366 `toggle_pin` 结构)。流程:`select(Conversation).where(id==...)` → 不存在抛 `AppException(1002,"会话不存在")` → 赋值 `ai_reply_mode` + `updated_at=datetime.now()``commit()``ws_manager.broadcast({"type":"ai_reply_mode_changed","data":{...}})`**try/except 包裹,广播失败只 warning,不回滚写库**)→ `success_response(data=AiReplyModeResponse(...).model_dump())`。需在文件头 import `ai_reply_mode` 常量与 `ws_manager` |
| `src/backend/tests/test_ai_reply_gate.py` | [新增] | 3 mode × 5 status = 15 组参数化断言,逐格比对 §3.3 两张真值表;额外覆盖「对象缺 `ai_reply_mode` 属性时退化为 DEFAULT」 |
| `src/backend/tests/test_ai_reply_mode_api.py` | [新增] | 端点用例:合法值 → code 0 且 DB 已改;非法值 `"xxx"` → code 1001;不存在 conv → code 1002;连续切换两次 → 以最后一次为准 |
**验收**`pytest tests/test_ai_reply_gate.py tests/test_ai_reply_mode_api.py` 全绿。
---
### T03 · 路径 A 门控 + 补齐坐席广播 | P0 | 依赖:T02
| 文件 | 标记 | 改动点 |
|------|------|--------|
| `src/backend/app/services/message_router.py` | [修改] | **① L176-179**`if self.ai_handler and conversation.status == "ai_handling":``if self.ai_handler and can_ai_auto_reply_path_a(conversation):`(保留 `self.ai_handler` 判空;`else` 分支加 `logger.info` 记录跳过原因,便于排障)<br>**② `_try_ai_reply`L458-563)末尾**(在 `return True` 之前、`ai_message``flush()` 拿到 id 之后):新增 `if should_push_to_agent(conversation):``await ws_manager.broadcast({"type":"new_message","data":{conversation_id, message_id, sender_type:"ai", sender_id:"ai_bot", sender_name:"Duckula(达寇拉)", content:result.content, msg_type:"text"}})`,字段对齐 `h5_ai_task.py:482` 的 payload 形状。**try/except 包裹**,广播失败只 warning(照抄 L502 注释风格)<br>**③** 文件头 import `ai_reply_gate``ws_manager`(注意:本文件当前**未** import ws_manager,需新增;建议函数内局部 import 以规避潜在循环依赖,参照 `h5_ai_task.py:342` 的局部 import 惯例) |
> **补充说明(解答 PRD Q1**:路径 A 原本**只有** `wecom_service.send_text_message()` 推员工,坐席侧**无任何 WS 广播链路**(消息虽落库但坐席端不实时可见)。本任务即为补齐该链路,且仅在 `employee_and_agent` 下开启。
**验收**`off` 态下路径 A 触发率 0`employee_and_agent` 下坐席端能实时收到 AI 回复气泡;`employee_only` 下坐席端无 AI 气泡(同现状)。
---
### T04 · 路径 B 门控(修复缺口)+ 推坐席语义收紧 | P0 | 依赖:T02
| 文件 | 标记 | 改动点 |
|------|------|--------|
| `src/backend/app/api/h5.py` | [修改] | **L939** `asyncio.create_task(process_h5_ai_reply(...))` 外层包 `if can_ai_auto_reply_path_b(conversation):``else` 分支 `logger.info(f"跳过AI自动回复: conv={conversation.id}, status={conversation.status}, mode={conversation.ai_reply_mode}")`。<br>⚠️ **门控必须在 `create_task` 之前**,不得下沉进任务内部(否则仍会白占 DB session + Dify 配额)。<br>L950 的 `success_response` 保持不变(`ai_reply:null` 语义已兼容"未触发" |
| `src/backend/app/api/ws.py` | [修改] | **L472** 同上包裹(`conversation` 对象由 L368 `db.get(Conversation, conversation_id)` 提供,作用域内可用)。这是 option_select 选项反馈路径,同样必须门控 |
| `src/backend/app/tasks/h5_ai_task.py` | [修改] | 4 处坐席广播点改为条件广播(**逐点见附录 B**):<br>**① L482** `_persist_and_push``new_message` + `conversation_updated` 整块包 `if should_push_to_agent(conversation):`<br>**② L749** `_persist_and_push_structured`:同上(含 `selected_options` 快照分支一并包入)<br>**③ L891** `_handle_byod_query`:同上<br>**④ L1602-1608** `_step_call_dify` 的坐席 `ai_thinking` 广播:同上(`conversation` 已在入参中)<br>⚠️ **不要动** `broadcast_to_employees(...)` 的任何调用(L378/L443/L685/L734/L871/L1289/L1576/L1596/L1613)——员工侧推送在三态下只要触发就必须照常送达。<br>⚠️ `_persist_and_push_solution`L321-388)**无坐席广播**,本次无需改动 |
**验收**`status=serving` 下 H5 发消息 → 后台任务零创建(日志可证);`off` 态同理;`employee_only` 下坐席端**不再**出现 AI 气泡;`employee_and_agent` 下坐席端正常可见。
---
### T05 · 坐席前端:🤖 三态开关 | P0 | 依赖:T02(接口契约)
| 文件 | 标记 | 改动点 |
|------|------|--------|
| `src/frontend-agent/src/types/ai-reply-mode.ts` | [新增] | `export type AiReplyMode = 'employee_only' \| 'employee_and_agent' \| 'off'``AI_REPLY_MODE_OPTIONS` 常量数组(`value` / `label` / `desc`,文案照抄 PRD §6.2);`DEFAULT_AI_REPLY_MODE = 'employee_only'` |
| `src/frontend-agent/src/api/conversation.ts` | [修改] | ① `Conversation` 接口(L31)增 `ai_reply_mode: AiReplyMode`;② 新增 `export async function updateAiReplyMode(conversationId: string, mode: AiReplyMode)``apiClient.put(`/conversations/${id}/ai-reply-mode`, { mode })`(仿 L199 `togglePin` 写法) |
| `src/frontend-agent/src/components/chat/AiReplyModeSwitch.vue` | [新增] | `<script setup lang="ts">`。Props: `conversationId: string``modelValue: AiReplyMode`。结构:`el-popover(placement="bottom-start", trigger="click", :width="280")` + `#reference` 槽内 `<button class="tb-btn">🤖<span class="tb-tip">AI自动回复</span></button>` + 内容区 `el-radio-group`v-model 绑本地 ref)三项,每项带副标题描述 + 底部「当前:xxx」。<br>逻辑:选中 → **先乐观改本地 + emit**P1-1)→ `await updateAiReplyMode()` → 成功 `ElMessage.success('已切换为:…')` + `popoverVisible=false`(P1-3);失败 → 回滚 ref 到原值 + `ElMessage.error` + **popover 保持打开**。<br>样式(P2-1):按 mode 给按钮加 class —— `employee_only` 正常、`employee_and_agent``.tb-btn--active`(复用 AiAssistToolbar 既有高亮态)、`off``.tb-btn--muted`(新增,灰度 + `opacity:.5`)。**复用 `.tb-btn` 既有样式,不重新造尺寸** |
| `src/frontend-agent/src/components/chat/ReplyBox.vue` | [修改] | 在 `.chat-toolbar`L40**内、`<div class="toolbar-left">`L42)之前**插入:`<AiReplyModeSwitch :conversation-id="conversationStore.currentConversation?.id || ''" :model-value="conversationStore.currentConversation?.ai_reply_mode || 'employee_only'" @update:model-value="onAiReplyModeChange" />` + 紧随一个 `<div class="tb-sep"></div>`PRD §6.1 最左独立段)。`<script setup>` 中 import 组件 + 定义 `onAiReplyModeChange(mode)``conversationStore.setAiReplyMode(convId, mode)`。<br>⚠️ 会话未选中(`currentConversation` 为空)时按钮应 `disabled` |
| `src/frontend-agent/src/stores/conversation.ts` | [修改] | ① `setAiReplyMode(convId: string, mode: AiReplyMode)`:在 `conversations.value` 中定位并就地更新(保证 `currentConversation` computed 响应);② `handleAiReplyModeChanged(data: {conversation_id, ai_reply_mode})`:WS 回调,同样就地更新(**幂等**,与本端乐观更新结果一致时无副作用);③ 二者均需 `export` 到 store 返回对象 |
| `src/frontend-agent/src/composables/useWebSocket.ts` | [修改] | `handleMessage` switchL313)新增 `case 'ai_reply_mode_changed': if (msg.data) conversationStore.handleAiReplyModeChanged(msg.data); break`(插在 `conversation_updated`(L337) 之后) |
**验收**:🤖 与 4 个主动 AI 按钮视觉分离(分列工具栏两端、中间隔 `.tb-sep`);三态可切、即时生效;切会话各自恢复;断网切换会回滚报错;另一端切换本端 3s 内同步。
---
### 5.1 任务依赖图
```mermaid
graph TD
T01["<b>T01 数据层</b><br/>constants + model<br/>+ alembic 058 + schemas<br/><i>P0</i>"]
T02["<b>T02 门控内核 + API</b><br/>ai_reply_gate.py<br/>+ PUT /ai-reply-mode<br/>+ 2 个单测<br/><i>P0</i>"]
T03["<b>T03 路径A</b><br/>message_router.py<br/>门控 + 补坐席广播<br/><i>P0</i>"]
T04["<b>T04 路径B</b><br/>h5.py / ws.py 门控<br/>+ h5_ai_task 4处收紧<br/>🔴含缺口修复<br/><i>P0</i>"]
T05["<b>T05 坐席前端</b><br/>AiReplyModeSwitch.vue<br/>+ ReplyBox / store / WS<br/><i>P0</i>"]
T01 --> T02
T02 --> T03
T02 --> T04
T02 -.->|"仅依赖接口契约<br/>可与 T03/T04 并行"| T05
style T01 fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style T02 fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
style T03 fill:#fff3e0,stroke:#f57c00,stroke-width:2px
style T04 fill:#ffebee,stroke:#d32f2f,stroke-width:3px
style T05 fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
```
**关键路径**T01 → T02 → T04(T04 含 P0 缺口修复,风险最高,建议优先排期)。
**并行窗口**T02 完成后,T03 / T04 / T05 三者互不冲突,可三线并行。
---
## 六、依赖包
**无新增依赖包。** 全部改动落在既有技术栈内:
| 已有依赖 | 用途 | 位置 |
|---------|------|------|
| `alembic` | 数据库迁移 058 | `requirements.txt` 已含 |
| `sqlalchemy>=2.0` | `Mapped` / `mapped_column` | 已含 |
| `pydantic>=2` | `field_validator` | 已含 |
| `element-plus` | `el-popover` / `el-radio-group` / `ElMessage` | `package.json` 已含 |
---
## 七、共享知识(跨文件约定)
### 7.1 枚举字符串常量(唯一真源)
| 语义 | 字符串字面量 | 后端常量 | 前端常量 |
|------|------------|---------|---------|
| 仅回复员工(默认) | `"employee_only"` | `AiReplyMode.EMPLOYEE_ONLY` | `'employee_only'` |
| 回复员工 + 推送坐席 | `"employee_and_agent"` | `AiReplyMode.EMPLOYEE_AND_AGENT` | `'employee_and_agent'` |
| 关闭自动回复 | `"off"` | `AiReplyMode.OFF` | `'off'` |
> ✅ **禁止**在业务代码里裸写这三个字符串——后端一律 `from app.constants.ai_reply_mode import ...`,前端一律从 `types/ai-reply-mode.ts` 导入。唯一例外:Alembic 迁移文件(迁移必须自包含、不依赖应用代码,允许硬编码)。
### 7.2 字段与默认值
- DB 列:`conversations.ai_reply_mode VARCHAR(20) NOT NULL DEFAULT 'employee_only'`
- ORM 双保险:`default="employee_only"`Python 侧)+ `server_default="employee_only"`DB 侧)
- 读取兜底:`getattr(conv, "ai_reply_mode", None) or DEFAULT`
- **与 `status` 严格正交**:任何代码**不得**因 mode 变化去写 `status`,反之亦然
### 7.3 WS 频道与事件名
| 事件名 | 通道 | 受众 | 本次动作 |
|--------|------|------|---------|
| `ai_reply_mode_changed` | `ws_manager.broadcast()` | 全体坐席 | 🆕 新增 |
| `new_message` (sender_type=`ai`) | `ws_manager.broadcast()` | 全体坐席 | 收紧:仅 `employee_and_agent` 时发 |
| `conversation_updated` | `ws_manager.broadcast()` | 全体坐席 | 收紧:随 AI 回复一同门控 |
| `ai_thinking` | `ws_manager.broadcast()` | 全体坐席 | 收紧:同上 |
| `ai_reply` / `ai_reply_chunk` / `ai_reply_failed` | `ws_manager.broadcast_to_employees()` | 员工 | **不动**(员工侧始终照常) |
### 7.4 错误码
| code | 含义 | 使用位置 |
|------|------|---------|
| `1001` | 参数非法(mode 不在合法集合) | `PUT /ai-reply-mode` |
| `1002` | 会话不存在 | `PUT /ai-reply-mode` |
响应统一走 `app/utils/response.py``success_response()` / `AppException()`,HTTP 状态保持 200,业务码在 body。
### 7.5 日志约定(便于 QA 与线上排障)
门控命中跳过时**必须**打 `logger.info`,统一格式:
```
[ai_reply_gate] 跳过AI自动回复: path={A|B}, conv_id={id}, status={status}, mode={mode}
```
### 7.6 UI 文案(前后端一致,照抄 PRD §6.2)
| mode | 单选项标题 | 副标题 | 切换成功提示 |
|------|----------|--------|------------|
| `employee_only` | 仅回复员工(默认) | AI 只回复员工,与现状一致 | 已切换为:AI回复-仅员工 |
| `employee_and_agent` | 回复员工 + 推送坐席 | AI 回复员工,坐席端同步可见 | 已切换为:AI回复-员工+坐席 |
| `off` | 关闭自动回复 | AI 不再自动回复任何消息 | 已切换为:AI回复-关闭 |
### 7.7 本次**明确不做**的事(防止范围蔓延)
- ❌ 不改 `status` 状态机(`ai_handling→queued→serving→pending_close→resolved`
- ❌ 不中断在途 `asyncio.create_task`ADR-6
- ❌ 不动 4 个主动 AI 按钮(💡🎚️🖌️🔄)——与本开关**完全正交**
- ❌ 不动员工端 H5`src/frontend-h5`)任何代码
- ❌ 不做 P2-2 快捷键 / P2-3 审计留痕 / P2-4 会话详情只读入口(本期不排)
- ❌ 不做全局默认模式配置(作用域严格限单会话)
---
## 八、待明确事项(Open Questions
> PRD §7 的 Q1~Q7 中,Q1/Q2/Q3/Q4/Q5/Q7 已由主理人与本方案拍板,**不再列为待确认**(见 §1.3 ADR 与 §3.3 真值表)。仅剩以下两项需要确认:
| # | 问题 | 影响面 | 建议 | 需谁拍板 |
|---|------|--------|------|---------|
| **O1** | **`employee_only` 下坐席端不再看到 AI 回复**——这是三态语义的必然结果,但属于**对现状的行为收紧**(路径 B 原为无条件广播)。若线上坐席已习惯"边看 AI 边待命",默认态改变会引发体感落差。 | 中。可能引发坐席"消息怎么少了"的反馈 | 两个选项:**(a)** 按本方案执行,用培训/公告消化;**(b)** 把默认值改为 `employee_and_agent`(但与 PRD "默认 employee_only = 等同现状" 的表述冲突,需 PRD 同步改)。**架构师倾向 (a)**,理由:PRD 明文定义默认 = `employee_only`,且"员工-AI 会话对坐席不可见"是该态的定义本身 | 产品经理(对应 PRD Q6 的延伸) |
| **O2** | **多 worker 部署下 WS 广播的可达性**`h5_ai_task.py` 头部注释(L11-12)已明确记载:"ws_manager 是进程内单例,多 worker 时后台任务与员工 WS 连接可能不在同进程,broadcast 会静默丢失(约 50%"。新增的 `ai_reply_mode_changed` 广播同样受此限制——若线上多 worker,其他坐席端的实时同步会概率性失败。 | 低(有兜底)。切换发起端本身走 HTTP 响应,必定正确;只影响"其他端"的实时同步 | 本期**不引入 Redis Pub/Sub**(超出需求范围)。兜底方案:其他端在切会话/刷新时通过 `GET /api/conversations` 拿到权威 `ai_reply_mode`,最终一致。**需运维确认当前生产 worker 数**:若为单 worker 则无影响;若多 worker,需在验收标准中把"跨端实时同步"降级为"非强保证" | 运维 + QA(确认部署形态与验收口径) |
---
## 附录 A · 代码事实核验表(本方案所有行号均已实读确认)
| 断言 | 文件:行 | 核验结果 |
|------|--------|---------|
| 项目使用 Alembic,当前 head | `alembic/versions/057_troubleshooting_templates.py` | ✅ `revision='057_troubleshooting_templates'``down_revision='056_add_moderation_tables'`,无其他分支头 |
| 迁移文件幂等写法惯例 | `057_...py:42-45` | ✅ `bind=op.get_bind()` + `sa.inspect(bind)` + `has_table` 守卫 |
| `Conversation``ai_reply_mode` 字段 | `models/conversation.py:19-399` | ✅ 全文无该字段;`status``String(20)` 非原生 enum |
| 路径 A 门控现状 | `services/message_router.py:176-179` | ✅ `if self.ai_handler and conversation.status == "ai_handling":` |
| 路径 A **无坐席 WS 广播** | `services/message_router.py:458-563` | ✅ 仅 `wecom_service.send_text_message()` + `Message` 落库,**全函数无 ws_manager 调用**(解答 PRD Q1 |
| 路径 B 触发点 1,零门控 | `api/h5.py:939-948` | ✅ 裸 `asyncio.create_task(process_h5_ai_reply(...))` |
| 路径 B 触发点 2,零门控 | `api/ws.py:472-487` | ✅ 同上;`conversation``ws.py:368` `db.get()` 提供,作用域内可用 |
| H5 新建会话初始 status | `api/h5.py:876` | ✅ `status="ai_handling"` → 落在路径 B 白名单内,不误伤 |
| `ws_manager` 接口 | `services/ws_manager.py:115/140/220/260` | ✅ `send_to_agent` / `broadcast` / `send_to_employee` / `broadcast_to_employees``active_connections: Dict[str, WebSocket]`(L51) |
| 端点实现模板 | `api/conversations.py:366-388` | ✅ `@router.post` + `@require_permission("conversation","update","own")` + `success_response(data=...)` |
| Schema 校验模板 | `schemas/conversation.py:113-132` | ✅ `ConversationStatusUpdate``@field_validator` 写法可直接仿制 |
| 前端工具栏结构 | `components/chat/ReplyBox.vue:40-102` | ✅ `.chat-toolbar` > `.toolbar-left`(L42) + `.tb-sep`(L90) + `<AiAssistToolbar>`(L93) |
| 主动 AI 按钮样式 | `components/chat/ai-assist/AiAssistToolbar.vue:20-40` | ✅ `.toolbar-right` > `.tb-btn.tb-btn--ai``.tb-btn--active` 高亮态可复用 |
| 前端 WS 分发点 | `composables/useWebSocket.ts:313-342` | ✅ `handleMessage` switch`case 'conversation_updated'` 在 L337 |
| 前端 API 模板 | `api/conversation.ts:199-210` | ✅ `togglePin` 可仿制 |
## 附录 B · `h5_ai_task.py` 坐席广播点清单(T04 逐点核对用)
| # | 函数 | 行号 | 广播内容 | 本次处理 |
|---|------|------|---------|---------|
| 1 | `_persist_and_push_solution` | L378 | `broadcast_to_employees(ai_reply)` | ⬜ **不动**(员工侧,且本函数无坐席广播) |
| 2 | `_persist_and_push` | **L482 / L494** | `broadcast(new_message)` + `broadcast(conversation_updated)` | ✅ 整块包 `should_push_to_agent()` |
| 3 | `_persist_and_push_structured` | **L749 / L776** | `broadcast(new_message)` + `broadcast(conversation_updated)`(含 `selected_options` 快照) | ✅ 整块包 `should_push_to_agent()` |
| 4 | `_handle_byod_query` | **L891 / L904** | `broadcast(new_message, byod_card)` + `broadcast(conversation_updated)` | ✅ 整块包 `should_push_to_agent()` |
| 5 | `_step_call_dify` | **L1603** | `broadcast(ai_thinking)` 给坐席 | ✅ 包 `should_push_to_agent()` |
| 6 | 员工侧全部推送 | L378/L443/L685/L734/L871/L1289/L1576/L1596/L1613 | `broadcast_to_employees(...)` | ⬜ **一律不动** |
---
**文档结束** · 架构师 高见远(Gao · 2026-08-04