本提交为 .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-*/
44 KiB
技术方案 - 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)
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)
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 重点回归):
- 路径 B 在
employee_only下不再向坐席镜像广播 AI 回复(原为无条件广播)。这是三态语义的必然结果——"仅回复员工"意味着员工-AI 对话对坐席不可见。employee_and_agent在「镜像 + 专属」两条路径上同时生效:镜像广播触发new_message/conversation_updated(与 a 同链路),专属推送触发ai_reply_for_agent(带【AI 协助】前缀)。
当前实现已与本口径一致(详见 §3.4.2):
# 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.py4 处坐席广播点(L540 / L817 / L970 / L1684)→if agent_mirror_enabled(_conv_mode):守卫src/backend/app/tasks/h5_ai_task.py2 处专属推送(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
{ "mode": "employee_and_agent" }
成功响应 200(统一 success_response 包装)
{
"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 的完整调用链
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 门控与推送分支
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 记录跳过原因,便于排障)② _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 注释风格)③ 文件头 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}")。⚠️ 门控必须在 create_task 之前,不得下沉进任务内部(否则仍会白占 DB session + Dify 配额)。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): ① L482 _persist_and_push:new_message + conversation_updated 整块包 if should_push_to_agent(conversation):② L749 _persist_and_push_structured:同上(含 selected_options 快照分支一并包入)③ L891 _handle_byod_query:同上④ L1602-1608 _step_call_dify 的坐席 ai_thinking 广播:同上(conversation 已在入参中)⚠️ 不要动 broadcast_to_employees(...) 的任何调用(L378/L443/L685/L734/L871/L1289/L1576/L1596/L1613)——员工侧推送在三态下只要触发就必须照常送达。⚠️ _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」。逻辑:选中 → 先乐观改本地 + emit(P1-1)→ await updateAiReplyMode() → 成功 ElMessage.success('已切换为:…') + popoverVisible=false(P1-3);失败 → 回滚 ref 到原值 + ElMessage.error + popover 保持打开。样式(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 |
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 任务依赖图
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