Files
wecom_it_smart_desk/docs/02-技术文档/技术方案-REQ-坐席-010-AI回复三态开关.md
T
Simon facc04aa65 chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .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-*/
2026-08-07 22:31:32 +08:00

44 KiB
Raw Blame 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_handlingh5.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)、AiReplyModeResponseConversationResponseai_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(企微 Appmessage_router.py:176)— 是否触发 AI 自动回复

status \ mode employee_only employee_and_agent off
ai_handling 触发(仅推员工) 触发(推员工 + 广播坐席
queued / serving / pending_close / resolved (既有行为,不变)

路径 BH5/WSh5.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:
    """路径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):

# 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

{ "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_stateL220-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;③ ConversationResponseL240)增字段 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-179if 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_replyL458-563)末尾(在 return True 之前、ai_messageflush() 拿到 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_gatews_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 触发率 0employee_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_pushnew_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_solutionL321-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: stringmodelValue: AiReplyMode。结构:el-popover(placement="bottom-start", trigger="click", :width="280") + #reference 槽内 <button class="tb-btn">🤖<span class="tb-tip">AI自动回复</span></button> + 内容区 el-radio-groupv-model 绑本地 ref)三项,每项带副标题描述 + 底部「当前:xxx」。
逻辑:选中 → 先乐观改本地 + emitP1-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-toolbarL40内、<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 switchL313)新增 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.pysuccess_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_taskADR-6
  • 不动 4 个主动 AI 按钮(💡🎚️🖌️🔄)——与本开关完全正交
  • 不动员工端 H5src/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 守卫
Conversationai_reply_mode 字段 models/conversation.py:19-399 全文无该字段;statusString(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 同上;conversationws.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_employeesactive_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 switchcase '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