777 lines
25 KiB
Markdown
777 lines
25 KiB
Markdown
|
|
# 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 满载跑批产出,待评审*
|