facc04aa65
本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交 (5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。 一、docs 结构整改(整改 #14) 根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入, 随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录 树并存于 docs/,共 791 文件、双分类体系冲突。 修复动作: - b2 同名异主题文件改名迁移保全 9 个 - C 类 39 个孤立文件按主题正确归类 - A/B1 类 222 个重复文件删除(新结构已有内容副本) - 9 个旧独有空目录删除 - 270 处内部引用按 verified 映射改写 - 整改记录 #14 登记于 04-运维文档/部署运维 结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。 残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。 二、compose 双目录对齐(消除踩坑 A) - docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist 改为 src/frontend-*/dist(h5 / agent / admin / terminal) - docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/ - 效果:本地 docker compose up 不再把根目录 stale dist 挂回, 与线上一致,分叉隐患消除(已 docker compose config 校验通过) 防复发铁律: - 重构须提交;仓库修复须 git stash -u 或先 commit - 新结构须 git add 并提交,避免再次 untracked 复活 - H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
697 lines
44 KiB
Markdown
697 lines
44 KiB
Markdown
# 技术方案 - 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` | ❌(既有行为,不变) | ❌ | ❌ |
|
||
|
||
**路径 B(H5/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:
|
||
"""路径B(H5/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)` 约定(业务码在 body,HTTP 保持 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` switch(L313)新增 `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
|