# 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 数据模型

```python
# 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 迁移

```python
# 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 客户端

```python
# 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 服务

```python
# 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 端点

```python
# 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` (主容器)

```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`

```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

```ts
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. 关联文档

- [[阶段2-3-任务]] §3: 阶段 3 任务拆解
- [[阶段4-5-规划]] §4.1.2: 知识库迭代
- [[外部系统集成]]: Dify 集成细节
- [[风险跟踪表]]: M-1 / M-7 / H-9 相关

---

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