Files
wecom_it_smart_desk/docs/Wingman设计.md
T
Simon 93ba41ed79 feat: 审批流程模块 (T审批A审批)
- 新增 backend/app/api/approval.py 审批API
- 前端H5支持发起审批、审批操作
- 添加审批卡片弹窗组件
- 路由注册审批模块
2026-06-15 09:32:41 +08:00

25 KiB

AI Wingman 设计文档

版本: 1.0 生成日期: 2026-06-15 作者: Claude(满载跑批产出) 状态: 设计阶段,待评审 关联: 阶段2-3-任务 §3 / 阶段4-5-规划 §4.1.2


📌 1. 概述

1.1 什么是 Wingman

Wingman(僚机) = 坐席工作台的 AI 辅助系统,在坐席处理会话时实时提供:

  • 草稿回复(坐席打字 → AI 实时给草稿)
  • 自动摘要(会话结束 → AI 200 字摘要)
  • 知识推荐(对话中识别关键字 → 推 FAQ)
  • 排查步骤(员工描述问题 → AI 给 step-by-step)

目标: 减少坐席重复劳动 50%,提升响应速度 30%。

1.2 与现有 AI 的区别

维度 现有 AI(企微机器人) Wingman
用户 员工 坐席
触发 员工提问 坐席打字 / 结束会话 / 关键字
输出 完整回复给员工 草稿 / 摘要 / 步骤 给坐席审
集成 企微 1 对 1 坐席工作台 右侧栏
作用 自助解决 辅助坐席

1.3 关键指标

指标 目标
草稿采纳率 ≥ 50%
摘要准确率 ≥ 80%
知识命中率 ≥ 30%
排查步骤有效率 ≥ 60%
响应延迟 P95 ≤ 1.5 秒

📌 2. 架构

2.1 系统架构

┌─────────────────────────────────────────────────────────┐
│  坐席浏览器 (frontend-agent)                            │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐    │
│  │  对话区     │  │  右侧栏     │  │  标注面板   │    │
│  │  (主)      │  │  (Wingman)  │  │  (阶段4)    │    │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘    │
│         │                 │                 │           │
└─────────┼─────────────────┼─────────────────┼───────────┘
          │ HTTP/WS         │ WS(实时推送)    │
          ▼                 ▼                 ▼
┌─────────────────────────────────────────────────────────┐
│  FastAPI 后端                                           │
│  ┌──────────────────────────────────────────────┐      │
│  │  WingmanService (ai_wingman.py)              │      │
│  │  ├── draft_reply()        草稿回复          │      │
│  │  ├── summarize()          自动摘要          │      │
│  │  ├── recommend_knowledge() 知识推荐          │      │
│  │  └── troubleshoot()       排查步骤          │      │
│  └────────────────┬─────────────────────────────┘      │
│                   │                                     │
│  ┌────────────────▼─────────────────────────────┐      │
│  │  DifyClient (dify_client.py)                 │      │
│  │  - 流式 / 阻塞 / 异步 统一封装               │      │
│  └────────────────┬─────────────────────────────┘      │
│                   │                                     │
│  ┌────────────────▼─────────────────────────────┐      │
│  │  Redis 缓存 + 限流 + 配额                     │      │
│  └──────────────────────────────────────────────┘      │
└────────────────────┬────────────────────────────────────┘
                     │ HTTPS
                     ▼
┌─────────────────────────────────────────────────────────┐
│  Dify 平台 (企微 AI 机器人已在用)                        │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │  工作流 A    │  │  工作流 B    │  │  工作流 C    │  │
│  │  草稿回复    │  │  摘要生成    │  │  排查步骤    │  │
│  └──────────────┘  └──────────────┘  └──────────────┘  │
└─────────────────────────────────────────────────────────┘

2.2 数据流

2.2.1 草稿回复(实时)

[坐席打字] 
  → debounce 300ms 
  → 调 WingmanService.draft_reply(conv_id, last_employee_msg, context)
  → Dify 流式生成
  → 推 WS 推前端
  → 右侧栏显示 3 条草稿
  → 坐席点"采用" → 草稿填入输入框

2.2.2 自动摘要(异步)

[坐席点"结单"]
  → 后端结单逻辑
  → 触发 WingmanService.summarize(conv_id)
  → 异步任务(BackgroundTasks)
  → 调 Dify 摘要工作流
  → 存 `conversations.summary`
  → 推 WS 通知"摘要已生成"
  → 坐席可编辑确认

2.2.3 知识推荐(被动)

[员工发消息]
  → 消息路由服务(message_router)
  → 检测关键字(VPN / 密码 / Outlook 等)
  → 命中 → 调 WingmanService.recommend_knowledge(keyword)
  → 推 WS 推 5 条 FAQ
  → 坐席右侧栏显示

2.2.4 排查步骤(主动)

[坐席点"AI 排查"]
  → 弹窗选问题分类
  → 输入员工描述
  → 调 WingmanService.troubleshoot(category, description)
  → 调 Dify 排查工作流
  → 推 5-7 步 step-by-step
  → 坐席可发给员工(企微应用消息)

2.3 部署架构

  • Dify: 已有(企微 AI 机器人用)
  • Wingman 工作流: 新建(3 个工作流)
  • Redis 缓存: 已有,加 wingman:draft:{conv_id}:{msg_hash} key
  • WS: 已有 ws_manager,扩展推送 wingman_event 类型

📌 3. 后端实现

3.1 数据模型

# backend/app/models/wingman.py
from sqlalchemy import Column, String, Integer, DateTime, JSON, ForeignKey
from .base import Base

class WingmanDraft(Base):
    """草稿回复(短期存储,坐席可重生成)"""
    __tablename__ = "wingman_drafts"
    id = Column(Integer, primary_key=True)
    conv_id = Column(String, ForeignKey("conversations.id"))
    agent_id = Column(String, ForeignKey("agents.id"))
    # 触发消息(员工最后一条)
    trigger_message_id = Column(String, ForeignKey("messages.id"))
    # 草稿内容(3 条)
    drafts = Column(JSON)  # ["draft1", "draft2", "draft3"]
    # 上下文
    context_messages = Column(JSON)  # 最近 10 条消息
    # 状态
    accepted_index = Column(Integer, nullable=True)  # 坐席点了第几条(0/1/2)
    created_at = Column(DateTime)
    expires_at = Column(DateTime)  # 5 分钟后过期


class WingmanSummary(Base):
    """会话摘要"""
    __tablename__ = "wingman_summaries"
    id = Column(Integer, primary_key=True)
    conv_id = Column(String, ForeignKey("conversations.id"), unique=True)
    summary = Column(String(2000))  # AI 生成
    edited_summary = Column(String(2000))  # 坐席改后
    final_summary = Column(String(2000))  # 最终(edited or summary)
    agent_id = Column(String, ForeignKey("agents.id"))
    model = Column(String)  # 用的模型
    created_at = Column(DateTime)
    updated_at = Column(DateTime)


class WingmanKnowledgeHit(Base):
    """知识库命中记录(供阶段 4 分析)"""
    __tablename__ = "wingman_knowledge_hits"
    id = Column(Integer, primary_key=True)
    conv_id = Column(String)
    message_id = Column(String)
    knowledge_id = Column(Integer)
    agent_id = Column(String)
    helpful = Column(Integer, nullable=True)  # 坐席反馈
    created_at = Column(DateTime)

3.2 Alembic 迁移

# 013_add_wingman.py
def upgrade():
    op.create_table("wingman_drafts", ...)
    op.create_table("wingman_summaries", ...)
    op.create_table("wingman_knowledge_hits", ...)
    
    op.create_index("idx_wingman_drafts_conv", "wingman_drafts", ["conv_id"])
    op.create_index("idx_wingman_summaries_conv", "wingman_summaries", ["conv_id"])
    op.create_index("idx_wingman_hits_conv", "wingman_knowledge_hits", ["conv_id"])

3.3 Dify 客户端

# backend/app/services/dify_client.py
import httpx
from typing import AsyncIterator
from app.config import settings

class DifyClient:
    def __init__(self):
        self.base_url = settings.DIFY_BASE_URL  # https://dify.servyou-it.com/v1
        self.api_key = settings.DIFY_API_KEY
        self.timeout = settings.DIFY_TIMEOUT  # 默认 30s
    
    async def chat_messages(
        self,
        workflow_id: str,
        query: str,
        user: str,
        inputs: dict = None,
        stream: bool = True,
    ) -> AsyncIterator[dict] | dict:
        """流式 / 阻塞 调 Dify 工作流"""
        url = f"{self.base_url}/workflows/run"
        headers = {"Authorization": f"Bearer {self.api_key}"}
        body = {
            "inputs": inputs or {},
            "query": query,
            "user": user,
            "response_mode": "streaming" if stream else "blocking",
        }
        
        if stream:
            async with httpx.AsyncClient(timeout=self.timeout) as client:
                async with client.stream("POST", url, json=body, headers=headers) as resp:
                    async for chunk in resp.aiter_lines():
                        if chunk.startswith("data:"):
                            yield json.loads(chunk[5:].strip())
        else:
            async with httpx.AsyncClient(timeout=self.timeout) as client:
                resp = await client.post(url, json=body, headers=headers)
                return resp.json()

3.4 Wingman 服务

# backend/app/services/wingman.py
import asyncio
import hashlib
import json
from datetime import datetime, timedelta
from typing import List, Optional
from app.services.dify_client import DifyClient
from app.services.ws_manager import ws_manager
from app.config import settings

class WingmanService:
    def __init__(self):
        self.dify = DifyClient()
        self.redis = get_redis()
        self.cache_ttl = 300  # 5 分钟
    
    async def draft_reply(
        self,
        conv_id: str,
        agent_id: str,
        last_employee_msg: str,
        context: List[dict],
    ) -> List[str]:
        """生成 3 条草稿回复"""
        # 缓存 key(同输入同输出)
        ctx_hash = hashlib.md5(json.dumps(context, sort_keys=True).encode()).hexdigest()[:8]
        cache_key = f"wingman:draft:{conv_id}:{ctx_hash}"
        
        cached = self.redis.get(cache_key)
        if cached:
            return json.loads(cached)
        
        # 调 Dify 工作流
        drafts = []
        async for chunk in self.dify.chat_messages(
            workflow_id=settings.DIFY_DRAFT_WORKFLOW_ID,
            query=last_employee_msg,
            user=agent_id,
            inputs={
                "context": context[-10:],  # 最近 10 条
                "tone": "professional_friendly",
                "n_drafts": 3,
            },
            stream=False,  # 草稿不需要流式
        ):
            if chunk.get("event") == "workflow_finished":
                drafts = chunk["data"]["outputs"].get("drafts", [])
                break
        
        if not drafts:
            drafts = ["(AI 暂未生成草稿,请手动回复)"]
        
        # 缓存
        self.redis.setex(cache_key, self.cache_ttl, json.dumps(drafts))
        
        # 推 WS 给坐席
        await ws_manager.send_to_agent(agent_id, {
            "type": "wingman_draft",
            "conv_id": conv_id,
            "drafts": drafts,
        })
        
        return drafts
    
    async def summarize(self, conv_id: str, agent_id: str) -> str:
        """生成会话摘要(异步)"""
        # 取会话所有消息
        messages = await self._get_conv_messages(conv_id)
        
        # 调 Dify 摘要工作流
        summary = ""
        async for chunk in self.dify.chat_messages(
            workflow_id=settings.DIFY_SUMMARY_WORKFLOW_ID,
            query=f"会话 ID: {conv_id}",
            user=agent_id,
            inputs={
                "messages": messages,
                "max_words": 200,
                "focus": ["problem", "solution", "key_info"],
            },
            stream=False,
        ):
            if chunk.get("event") == "workflow_finished":
                summary = chunk["data"]["outputs"].get("summary", "")
                break
        
        # 存库
        if summary:
            await self._save_summary(conv_id, agent_id, summary)
            
            # 推 WS
            await ws_manager.send_to_agent(agent_id, {
                "type": "wingman_summary",
                "conv_id": conv_id,
                "summary": summary,
            })
        
        return summary
    
    async def recommend_knowledge(
        self,
        keyword: str,
        conv_id: str,
        agent_id: str,
        top_k: int = 5,
    ) -> List[dict]:
        """知识推荐(基于关键字 + 向量检索)"""
        # 向量检索(用 Dify 知识库 API)
        results = []
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{settings.DIFY_BASE_URL}/datasets/{settings.DIFY_DATASET_ID}/retrieve",
                headers={"Authorization": f"Bearer {self.dify.api_key}"},
                json={
                    "query": keyword,
                    "top_k": top_k,
                    "retrieval_model": {
                        "search_method": "hybrid",  # 向量 + 关键字
                    },
                },
            )
            results = resp.json().get("records", [])
        
        # 记录命中
        for r in results:
            await self._record_knowledge_hit(conv_id, agent_id, r["id"])
        
        # 推 WS
        await ws_manager.send_to_agent(agent_id, {
            "type": "wingman_knowledge",
            "conv_id": conv_id,
            "knowledge": results,
        })
        
        return results
    
    async def troubleshoot(
        self,
        category: str,
        description: str,
        agent_id: str,
    ) -> List[dict]:
        """排查步骤生成"""
        steps = []
        async for chunk in self.dify.chat_messages(
            workflow_id=settings.DIFY_TROUBLESHOOT_WORKFLOW_ID,
            query=description,
            user=agent_id,
            inputs={
                "category": category,
                "max_steps": 7,
                "format": "markdown",
            },
            stream=False,
        ):
            if chunk.get("event") == "workflow_finished":
                steps = chunk["data"]["outputs"].get("steps", [])
                break
        
        return steps

3.5 API 端点

# backend/app/api/ai_wingman.py
from fastapi import APIRouter, Depends, BackgroundTasks
from app.services.wingman import wingman
from app.dependencies import get_current_agent

router = APIRouter(prefix="/api/v1/ai", tags=["AI Wingman"])

@router.post("/draft")
async def gen_draft(
    body: DraftRequest,
    agent: Agent = Depends(get_current_agent),
):
    """生成草稿"""
    drafts = await wingman.draft_reply(
        conv_id=body.conv_id,
        agent_id=agent.id,
        last_employee_msg=body.last_message,
        context=body.context,
    )
    return {"drafts": drafts}

@router.post("/summary/{conv_id}")
async def gen_summary(
    conv_id: str,
    background: BackgroundTasks,
    agent: Agent = Depends(get_current_agent),
):
    """生成摘要(异步)"""
    background.add_task(wingman.summarize, conv_id, agent.id)
    return {"status": "queued"}

@router.post("/knowledge")
async def recommend(
    body: KnowledgeRequest,
    agent: Agent = Depends(get_current_agent),
):
    """知识推荐"""
    results = await wingman.recommend_knowledge(
        keyword=body.keyword,
        conv_id=body.conv_id,
        agent_id=agent.id,
    )
    return {"knowledge": results}

@router.post("/troubleshoot")
async def troubleshoot(
    body: TroubleshootRequest,
    agent: Agent = Depends(get_current_agent),
):
    """排查步骤"""
    steps = await wingman.troubleshoot(
        category=body.category,
        description=body.description,
        agent_id=agent.id,
    )
    return {"steps": steps}

@router.post("/draft/{draft_id}/accept")
async def accept_draft(
    draft_id: int,
    index: int,  # 0/1/2
    agent: Agent = Depends(get_current_agent),
):
    """采纳草稿"""
    await wingman.accept_draft(draft_id, index, agent.id)
    return {"status": "accepted"}

📌 4. 前端设计

4.1 右侧栏布局

┌─────────────────────────────────────┐
│  对话区 (主)         │  Wingman 栏  │
│  ┌─────────────┐    │  ┌────────┐  │
│  │ 员工消息     │    │  │ 草稿  │  │
│  │ 坐席消息     │    │  │ 摘要  │  │
│  │ ...         │    │  │ 知识  │  │
│  └─────────────┘    │  │ 步骤  │  │
│  [输入框 + 草稿采用]  │  └────────┘  │
└─────────────────────────────────────┘

4.2 组件

4.2.1 WingmanPanel.vue (主容器)

<template>
  <div class="wingman-panel">
    <el-tabs v-model="activeTab">
      <el-tab-pane label="草稿" name="draft">
        <DraftList :conv-id="convId" />
      </el-tab-pane>
      <el-tab-pane label="摘要" name="summary">
        <SummaryView :conv-id="convId" />
      </el-tab-pane>
      <el-tab-pane label="知识" name="knowledge">
        <KnowledgeList :conv-id="convId" />
      </el-tab-pane>
      <el-tab-pane label="步骤" name="troubleshoot">
        <TroubleshootTool :conv-id="convId" />
      </el-tab-pane>
    </el-tabs>
  </div>
</template>

4.2.2 DraftList.vue

<template>
  <div class="draft-list">
    <div v-if="loading" class="loading">
      <el-skeleton :rows="3" animated />
    </div>
    <div v-else>
      <el-card v-for="(draft, idx) in drafts" :key="idx" class="draft-card">
        <div class="draft-content">{{ draft }}</div>
        <div class="draft-actions">
          <el-button size="small" @click="accept(idx)">采用</el-button>
          <el-button size="small" @click="regenerate(idx)">重生成</el-button>
          <el-button size="small" type="text" @click="mark(idx, 'helpful')">👍</el-button>
          <el-button size="small" type="text" @click="mark(idx, 'not_helpful')">👎</el-button>
        </div>
      </el-card>
      <el-button v-if="!loading" @click="generate" type="primary" plain>重新生成</el-button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useWingman } from '@/composables/useWingman'
import { useConversationStore } from '@/stores/conversation'

const props = defineProps<{ convId: string }>()
const drafts = ref<string[]>([])
const loading = ref(false)
const { genDraft, acceptDraft, markDraft } = useWingman()
const convStore = useConversationStore()

// 监听最后员工消息变化 → 自动生成草稿
watch(
  () => convStore.lastEmployeeMessage,
  async (msg) => {
    if (!msg) return
    loading.value = true
    drafts.value = await genDraft(props.convId, msg, convStore.context)
    loading.value = false
  },
  { debounce: 300 } // 防抖 300ms
)

const accept = async (idx: number) => {
  convStore.setDraftInput(drafts.value[idx])
  await acceptDraft(props.convId, idx)
}

const regenerate = async (idx: number) => {
  // 单条重生成
}

const mark = async (idx: number, type: 'helpful' | 'not_helpful') => {
  await markDraft(props.convId, idx, type)
}
</script>

4.2.3 useWingman.ts composable

import { ref } from 'vue'
import { aiApi } from '@/api/ai'
import { useAgentStore } from '@/stores/agent'

export function useWingman() {
  const agentStore = useAgentStore()
  
  const genDraft = async (
    convId: string,
    lastMessage: string,
    context: any[],
  ): Promise<string[]> => {
    const resp = await aiApi.draft({
      conv_id: convId,
      last_message: lastMessage,
      context,
    })
    return resp.data.drafts
  }
  
  const acceptDraft = async (convId: string, index: number) => {
    await aiApi.acceptDraft(convId, index)
  }
  
  const markDraft = async (convId: string, index: number, type: string) => {
    await aiApi.markDraft(convId, index, type)
  }
  
  const genSummary = async (convId: string) => {
    return aiApi.summary(convId)
  }
  
  const recommendKnowledge = async (convId: string, keyword: string) => {
    return aiApi.knowledge({ conv_id: convId, keyword })
  }
  
  const troubleshoot = async (category: string, description: string) => {
    return aiApi.troubleshoot({ category, description })
  }
  
  return {
    genDraft,
    acceptDraft,
    markDraft,
    genSummary,
    recommendKnowledge,
    troubleshoot,
  }
}

📌 5. 性能与限流

5.1 限流

端点 限制
/ai/draft 20 次/分钟/坐席(打字频率)
/ai/summary 5 次/分钟/坐席
/ai/knowledge 30 次/分钟/坐席
/ai/troubleshoot 10 次/分钟/坐席

实现: backend/app/middleware/rate_limit.py (slowapi)

5.2 缓存

策略
草稿 同 conv + 同 context hash → 缓存 5 分钟
知识 向量检索结果 → 缓存 10 分钟
摘要 不缓存(每次新生成)

5.3 超时与降级

  • Dify 超时(> 10s)→ 返回"AI 暂不可用,请手动回复"
  • 配额耗尽(企业级 Dify 限额)→ 走备用 Dify 实例 或 降级到"无 AI"
  • 错误重试 1 次,二次失败 fallback

📌 6. 验收标准

6.1 功能验收

  • 坐席打字 → 右侧栏 1.5 秒内出现 3 条草稿
  • 草稿采用率 ≥ 50%(统计)
  • 会话结束 → 5 秒内生成 200 字摘要
  • 关键字命中 → 5 条 FAQ 推右侧栏
  • 排查步骤 → 5-7 步 markdown 格式

6.2 性能验收

  • 草稿响应 P95 ≤ 1.5 秒
  • 摘要响应 P95 ≤ 5 秒
  • 知识推荐 P95 ≤ 2 秒
  • 排查步骤 P95 ≤ 8 秒

6.3 集成验收

  • Dify 工作流 3 个跑通
  • 标注数据可查(阶段 4 用)
  • WS 推送实时(≤ 1 秒延迟)

📌 7. 风险与缓解

风险 等级 缓解
Dify API 限流 🟠 多实例 + 限流 + 缓存
草稿质量差(不专业) 🟡 Prompt 工程 + 反馈迭代
知识库召回率低 🟡 阶段 4 闭环优化
坐席不信任 AI 🟡 培训 + 反馈机制
隐私泄露(敏感信息) 🟠 输入脱敏 + 输出审查

📌 8. 实施路径

8.1 阶段 A:基础设施(2 周)

  1. Dify 客户端 + 限流中间件
  2. 4 个 API 端点(草稿/摘要/知识/排查)
  3. 数据模型 + Alembic 013
  4. 前端右侧栏骨架

8.2 阶段 B:Dify 工作流(2 周)

  1. 草稿工作流(Prompt + 测试)
  2. 摘要工作流
  3. 排查工作流
  4. 知识库导入现有 FAQ

8.3 阶段 C:联调(2 周)

  1. 前后端联调
  2. 性能调优
  3. 错误降级
  4. Beta 试用(内部 5 个坐席)

8.4 阶段 D:上线(1 周)

  1. 全量上线
  2. 监控指标
  3. 收集反馈
  4. 迭代优化

📌 9. 监控指标

指标 来源 看板
草稿生成数 / 采纳数 DB wingman_drafts 阶段 4 看板
摘要生成数 / 编辑数 DB wingman_summaries 阶段 4 看板
知识命中数 / 反馈 DB wingman_knowledge_hits 阶段 4 看板
草稿响应 P95 应用日志 Prometheus
Dify 调用失败率 应用日志 Prometheus
坐席使用率 DB 统计 阶段 4 看板

📌 10. 关联文档


本设计是 2026-06-15 Claude 满载跑批产出,待评审