Files
wecom_it_smart_desk/docs/02-技术文档/技术架构/技术方案-REQ-用户-006-智能推荐重构-v1.0.md
T

1023 lines
32 KiB
Markdown
Raw Normal View History

# 技术方案 - H5 智能推荐重构
> **REQ 编号**: REQ-用户-006
> **版本**: v1.0
> **日期**: 2026-07-28
> **作者**: 宋献 (Simon) + Duckula
> **依赖 PRD**: `docs/01-产品文档/05-用户端H5/PRD-REQ-用户-006-智能推荐重构-v1.0-Frozen.md`**§4.7 已冻结,必须遵守**)
> **依赖代码**:
> - `src/backend/app/services/asset_recommend_service.py`(现有 L1/L2/L3 实现)
> - `src/backend/app/config/assets.yaml`(现有 L1 关键词 + L3 角色配置)
> - `src/backend/app/tasks/h5_ai_task.py`(现有 _step_persist / _step_assets
> - `src/backend/app/services/ws_manager.py`(双 WS 通道:dynamic_recommend + asset_recommend
> - `src/frontend-h5/src/components/assistant/DynamicRecommend.vue`(前端右侧栏组件)
---
## 一、现状分析
### 1.1 现有实现(4 条触发源混跑)
| 来源 | 代码位置 | WS 通道 | 触发时机 | 现状问题 |
|------|---------|---------|---------|---------|
| **ADify action** | `h5_ai_task.py::_step_persist` line 595 | `dynamic_recommend` WS | T0 同步 | v2.3 审批类只推左边 |
| **BL1 关键词** | `_push_asset_recommends``asset_service.match_keywords()` | `asset_recommend` WS | T1 异步 | `if keyword in message_lower` 太硬,无语义 |
| **CL2 画像** | `_push_asset_recommends``asset_service.match_profile_triggers()` | `asset_recommend` WS | T1 异步 | 画像 API 拿不到时全部 if 跳过 |
| **DL3 角色** | `_push_asset_recommends``asset_service.get_by_role()` | `asset_recommend` WS | T1 异步 | 英文 key vs 中文 position,永远不命中 |
### 1.2 关键问题(5 类,与 PRD §1 对齐)
1. L1 关键词匹配太硬 → 易误命中("邮箱配置"也命中"邮箱"
2. L2 画像依赖外部 API → 拿不到时空 L2
3. L3 角色推荐不工作 → 中文/英文 key 不匹配
4. 双轨运行(A/B/C/D 各跑各的)→ 无去重、无排序
5. v2.3 审批类不出现在右侧 → 进度无法跨会话回看
### 1.3 与 PRD §4.7 决策对照
PRD §4.7 已冻结 5 个决策,技术方案必须全部落地:
- ① 冷启动 A → §7 客户端状态机(无则隐)
- ② 多源合并 B → §6 合并算法
- ③ 触发失败降级 B → §8 降级链路
- ④ 话题切换检测 B → §9 检测算法
- ⑤ 跨会话持久化 B → §10 localStorage 方案
---
## 二、目标与范围
### 2.1 改造目标
| 目标 | 衡量标准 |
|------|---------|
| 4 类触发源全部可观测 | 后端日志 / 前端埋点可追踪每条推荐的来源 ID |
| 多源合并生效 | 同 recommend_id 不重复;右侧栏 ≤3 张 |
| 冷启动符合"无则隐" | 进会话 5s 内右侧栏无任何卡片 |
| 跨会话持久化生效 | L2/L3/T2 推荐在 localStorage 可见 |
| 审批进度回流 | 来源 D(用户发起的审批)状态变化时回流右侧栏 |
### 2.2 改造范围
**In-scope**
- 后端 `asset_recommend_service.py` 重构(L1/L2/L3 三层 + 中文角色匹配)
- 后端新增 `recommend_progress` 表 + 订阅 API(T2 事件触发)
- 后端新增 WS type `recommend_update`(审批状态回流)
- 前端 `DynamicRecommend.vue` 重构(FIFO + 4 类卡片 + 上限 3 张)
- 前端 `RightPanel.vue` 适配(无标题 + 持久化展示)
- 前端新增 `recommendStore.ts`Pinia 状态机管理)
**Out-of-scope**
- 智能推荐算法升级(语义匹配/向量召回)→ v1.4+ 单独做
- 坐席端 AI 辅助 → 不在本次范围
- 多模态推荐 → PRD-REQ-AI-003 单独做
---
## 三、架构设计
### 3.1 整体架构图
```mermaid
graph TB
subgraph "前端 H5"
UI[RightPanel.vue<br/>右侧栏 UI]
DR[DynamicRecommend.vue<br/>推荐卡片渲染]
RS[recommendStore.ts<br/>Pinia 状态机]
LS[(localStorage<br/>持久化)]
end
subgraph "后端"
WS[ws_manager.py<br/>WebSocket 管理]
T1[h5_ai_task.py<br/>_step_persist T0]
T2[h5_ai_task.py<br/>_step_assets T1]
T3[recommend_event_listener.py<br/>T2 webhook/轮询]
AS[asset_recommend_service.py<br/>4 类触发源核心]
AR[assets.yaml<br/>L1+L3 配置]
EP[employee_profile_service.py<br/>L2 画像]
DB[(PostgreSQL<br/>recommend_progress)]
end
subgraph "外部"
DIFY[Dify 主推理<br/>itdesk_main]
WECOM[企微审批<br/>webhook]
HUORONG[火绒 API<br/>L2 画像源]
end
DIFY -- "action" --> T1
T1 -- "T0 WS" --> WS
T1 -- "触发 _step_assets" --> T2
EP -- "查画像" --> HUORONG
T2 -- "调 service" --> AS
AS -- "查 L1" --> AR
AS -- "查 L2" --> EP
AS -- "查 L3" --> AR
T2 -- "T1 WS" --> WS
WECOM -- "审批状态" --> T3
T3 -- "写库" --> DB
T3 -- "T2 WS" --> WS
WS -- "broadcast" --> UI
UI --> RS
RS --> DR
RS -- "读/写" --> LS
```
### 3.2 时序图:员工发起"VPN 申请"全链路
```mermaid
sequenceDiagram
participant E as 员工
participant H5 as H5 前端
participant BE as 后端
participant D as Dify
participant AS as Asset Service
participant W as 企微审批
participant LS as localStorage
E->>H5: 发送"我要申请 VPN"
H5->>BE: WS 消息
BE->>D: 调用主推理
D-->>BE: {text, action.approval_type=vpn_access}
Note over BE: T0 同步:action 非空且非审批类<br/>但 v2.3 后审批类只推左边
BE->>H5: WS ai_reply (含文字 + 操作步骤气泡)
BE->>BE: _step_persist 完成
BE->>BE: 触发 _step_assets (T1 异步)
BE->>AS: match_keywords("我要申请 VPN")
AS-->>BE: [vpn 关键词命中]
BE->>AS: match_profile_triggers(profile)
AS-->>BE: [L2 画像检测]
BE->>AS: get_by_role(profile.position)
AS-->>BE: [L3 角色匹配]
BE->>BE: §6 多源合并(去重+排序+上限3张)
BE->>H5: WS asset_recommend
H5->>LS: 持久化非对话级推荐
H5-->>E: 右侧栏显示 L1 VPN 卡 + L2 提醒(如有)+ L3 资源(如有)
E->>H5: 点击"申请 VPN"
H5->>W: 跳转企微审批表单
W-->>H5: 审批状态变化
W-->>BE: webhook 通知
BE->>BE: _step_persist + recommend_progress 写库
BE->>H5: WS recommend_update (type=progress)
H5->>LS: 更新持久化条目
H5-->>E: 右侧栏顶部插入审批进度卡
```
### 3.3 数据流图
```
[用户消息] → [Dify 主推理] → [action 字段]
[_step_persist (T0 同步)]
┌──────┴──────┐
↓ ↓
[ai_reply WS] [dynamic_recommend WS] ← 仅 action 非空且非审批
↓ ↓
[气泡渲染] [右侧栏渲染]
[_step_assets (T1 异步)]
[AssetRecommendService.process_dify_intent]
┌──────┼──────┬──────┐
↓ ↓ ↓ ↓
L1 L2 L3 dedup
↓ ↓ ↓ ↓
[assets.yaml] [profile] [role] [排序+上限3]
[asset_recommend WS (单个包)]
[前端 recommendStore]
[Pinia state + localStorage]
[DynamicRecommend.vue FIFO 渲染]
```
---
## 四、4 类触发源的技术实现
### 4.1 来源 ADify actionT0 同步)
**代码位置**`h5_ai_task.py::_step_persist` line 592-650
**改动点**:保持 v2.3 现状(审批类只推左边),仅优化非审批类的来源标记
```python
# v1.0 修改建议:在 recommend_data 中加 source: "dify_action"
recommend_data = {
...
"source": "dify_action", # 新增:标识来源 A
"trigger_timing": "T0", # 新增:标识触发时机
"layer": "A", # 新增:与 L1/L2/L3 同维度
...
}
```
**前端处理**`DynamicRecommend.vue` 接收时按 `source` 字段分流到不同渲染分支
### 4.2 来源 B:L1 关键词匹配(T1 异步)
**代码位置**`asset_recommend_service.py::match_keywords` line 170-189
**核心问题**`if keyword in message_lower` 太硬
**改造方案 v1.0(最小改动)**
| 改造项 | 实现方式 | 优先级 |
|--------|---------|--------|
| 4.2.1 **中文分词**(推荐) | 引入 `jieba` 库,对用户消息分词后再匹配 | P1(v1.2 考虑) |
| 4.2.2 **同义词表**(推荐) | `assets.yaml` 新增 `synonyms` 字段 | P1v1.2 考虑) |
| 4.2.3 **最小改动**v1.0 必做) | 保持 substring 匹配,但增加"词频权重" | P0 |
**v1.0 实现(最小改动版)**
```python
def match_keywords(self, message: str) -> List[RecommendCard]:
"""v1.0 改造:增加词频权重 + 消息长度归一化"""
cards = []
message_lower = message.lower()
msg_len = len(message_lower)
for keyword, asset in self.keyword_assets.items():
# 词频权重:出现越多权重越高(最多 1.0)
occurrences = message_lower.count(keyword)
if occurrences == 0:
continue
# 长度归一化:短消息中关键词占比高 → 权重加成
weight = min(1.0, occurrences * (len(keyword) / max(msg_len, 1)) * 10)
card = self._build_card_from_asset(keyword, asset, source='keyword_assets')
card.confidence = weight # 置信度反映匹配强度
cards.append(card)
return cards
```
**配置文件扩展**`assets.yaml`):
```yaml
# v1.0 新增:同义词表(可选)
synonyms:
"vpn": ["虚拟专用网络", "远程办公", "内网穿透", "aTrust"]
"邮箱": ["outlook", "邮件", "mail", "企业邮箱"]
"密码": ["pwd", "口令", "登录密码"]
# v1.0 新增:单关键词排除规则(防止误命中)
exclude_keywords:
- "邮箱" # 当消息同时包含"收件箱"时排除
- "电脑" # 当消息仅含"电脑"不命中,需配合"笔记本"/"硬件"
```
### 4.3 来源 CL2 画像触发(T1 异步)
**代码位置**`asset_recommend_service.py::match_profile_triggers` line 261-366
**核心问题**:画像 API 拿不到时全部 if 跳过
**改造方案 v1.0**
```python
def match_profile_triggers(self, profile: Dict) -> List[RecommendCard]:
"""v1.0 改造:画像 API 拿不到时降级到 DB 设备登记表"""
cards = []
# v1.0 新增:画像为空时的降级
if not profile or not profile.get('huorong_version'):
# 降级方案:从 DB 设备登记表查最近一次上报
cached_profile = self._get_cached_profile_from_db(profile.get('employee_id'))
if cached_profile:
profile = cached_profile
if not profile:
return cards # 实在拿不到就返回空(C → D 降级)
# 原 5 个 if 触发逻辑保持不变
...
```
**新增降级函数**
```python
def _get_cached_profile_from_db(self, employee_id: str) -> Optional[Dict]:
"""从 DB 设备登记表查最近一次画像缓存"""
# 查 device_registry 表,按 updated_at DESC 取最新一条
# 映射字段到 profile dict
...
```
### 4.4 来源 DL3 角色匹配(T1 异步)
**代码位置**`asset_recommend_service.py::get_by_role` line 191-216
**核心问题**:英文 key vs 中文 position 永远不命中
**改造方案 v1.0**
```python
def get_by_role(self, role: str) -> List[RecommendCard]:
"""v1.0 改造:中文岗位子串匹配 + role_assets 新增中文 key"""
cards = []
# v1.0 改造 1:精确匹配(保持原逻辑)
if role in self.role_assets:
asset = self.role_assets[role]
cards.append(self._build_card_from_asset(role, asset, source='role_assets'))
return cards
# v1.0 改造 2:子串匹配(中英文混合场景)
for role_key, asset in self.role_assets.items():
# 双向子串匹配:position 包含 key 或 key 包含 position
if role_key in role or role in role_key:
card = self._build_card_from_asset(role_key, asset, source='role_assets')
card.confidence = 0.7 # 子串匹配置信度低
cards.append(card)
continue
# v1.0 改造 3:别名匹配(基于配置文件的 aliases)
if self._match_role_alias(role, role_key):
card = self._build_card_from_asset(role_key, asset, source='role_assets')
card.confidence = 0.85
cards.append(card)
return cards
def _match_role_alias(self, position: str, role_key: str) -> bool:
"""基于角色别名表匹配"""
aliases = self.role_aliases.get(role_key, [])
return position in aliases or any(alias in position for alias in aliases)
```
**配置文件扩展**`assets.yaml`):
```yaml
# v1.0 新增:中文角色 key(与英文并存)
role_assets:
# 英文 key(保持兼容)
developer:
title: "开发者资源"
...
# v1.0 新增:中文 key(实际生产用)
开发:
alias_of: "developer"
开发工程师:
alias_of: "developer"
程序员:
alias_of: "developer"
财务:
alias_of: "finance"
财务人员:
alias_of: "finance"
会计:
alias_of: "finance"
管理员:
alias_of: "manager"
管理层:
alias_of: "manager"
新员工:
alias_of: "new_employee"
应届生:
alias_of: "new_employee"
# v1.0 新增:角色别名映射(更精细的匹配)
role_aliases:
developer:
- "研发"
- "技术"
- "前端"
- "后端"
- "测试"
- "运维"
- "DBA"
finance:
- "财务"
- "会计"
- "出纳"
- "审计"
manager:
- "主管"
- "经理"
- "总监"
- "VP"
```
---
## 五、3 种触发时机实现
### 5.1 T0 同步触发(Dify action 与气泡同帧)
**代码位置**`h5_ai_task.py::_step_persist`
**改动点**:保持现有逻辑,仅在 WS 数据包中加 `trigger_timing: "T0"` 标记
### 5.2 T1 异步触发(AI 回复后独立步骤)
**代码位置**`h5_ai_task.py::_step_assets`
**改动点**
```python
async def _step_assets(db, employee_id, content, result):
"""v1.0 改造:T1 异步触发,支持超时和失败降级"""
try:
# v1.0 新增:3s 超时(避免阻塞主流程)
async with asyncio.timeout(3.0):
asset_service = get_asset_recommend_service()
profile_service = get_employee_profile_service()
l1_recs = asset_service.match_keywords(content)
# L2 + L3 降级逻辑(与 §4.7.5 决策 ③ 对齐)
profile = None
try:
profile = await asyncio.wait_for(
profile_service.get_profile(employee_id),
timeout=1.0
)
except (asyncio.TimeoutError, Exception) as e:
logger.warning(f"[AssetRecommend] 画像获取失败/超时(C → D 降级): {e}")
l2_recs = asset_service.match_profile_triggers(profile or {})
# 全部失败兜底(D 失败 → §4.7.4 空状态)
if not profile:
role = None
else:
role = profile.position or ''
l3_recs = asset_service.get_by_role(role or '')
# §6 多源合并
all_recs = asset_service.merge_recommends(
l1_recs, l2_recs, l3_recs,
max_count=3
)
# 推送 WS
ws_msg = asset_service.build_ws_message(all_recs)
await ws_manager.broadcast_to_employees([employee_id], ws_msg)
except asyncio.TimeoutError:
logger.warning("[AssetRecommend] T1 异步触发超时(>3s),放弃本轮推荐")
except Exception as e:
logger.error(f"[AssetRecommend] T1 失败: {e}", exc_info=True)
```
### 5.3 T2 事件触发(审批状态变化驱动)
**新增模块**`app/services/recommend_progress_service.py`
```python
class RecommendProgressService:
"""v1.0 新增:审批进度回流服务"""
async def handle_approval_webhook(self, approval_id: str, status: str):
"""处理企微审批 webhook"""
progress = RecommendProgress(
employee_id=self._get_employee_id(approval_id),
approval_id=approval_id,
approval_type=self._get_approval_type(approval_id),
status=status,
updated_at=datetime.utcnow()
)
self.db.add(progress)
self.db.commit()
# T2 推送:recommend_update WS
ws_data = {
"type": "recommend_update",
"data": {
"recommend_id": f"progress_{approval_id}",
"source": "approval_progress",
"approval_type": progress.approval_type,
"status": status,
"updated_at": progress.updated_at.isoformat(),
"ttl_days": 7, # 完结后 7 天清除
}
}
await ws_manager.broadcast_to_employees([progress.employee_id], ws_data)
async def poll_pending_approvals(self):
"""v1.0 新增:60s 轮询兜底(防止 webhook 丢失)"""
while True:
await asyncio.sleep(60)
# 查最近 1 小时状态变化的审批
recent = self.db.query(RecommendProgress).filter(
RecommendProgress.updated_at > datetime.utcnow() - timedelta(hours=1)
).all()
for p in recent:
current_status = await self._fetch_remote_status(p.approval_id)
if current_status != p.status:
await self.handle_approval_webhook(p.approval_id, current_status)
```
**WebSocket 协议扩展**
| type | 来源 | 推送时机 | 用途 |
|------|------|---------|------|
| `dynamic_recommend` | A | T0 同步 | Dify action 卡片 |
| `asset_recommend` | B/C/D | T1 异步 | 关键词/画像/角色推荐 |
| `recommend_update` | T2 事件 | webhook + 轮询 | 审批进度回流(v1.0 新增) |
---
## 六、多源合并规则实现
### 6.1 去重算法
**代码位置**`asset_recommend_service.py` 新增 `merge_recommends` 方法
```python
def merge_recommends(
self,
*rec_lists: List[RecommendCard],
max_count: int = 3
) -> List[RecommendCard]:
"""v1.0 新增:多源合并(PRD §4.7.3 决策 ②)"""
# Step 1: 合并所有来源
all_recs = []
for recs in rec_lists:
all_recs.extend(recs)
# Step 2: 按 card_key 去重(同卡片保留 confidence 最高的)
dedup_map: Dict[str, RecommendCard] = {}
for rec in all_recs:
key = rec.id # 推荐用稳定 IDfallback 到 title
if key not in dedup_map or rec.confidence > dedup_map[key].confidence:
dedup_map[key] = rec
deduped = list(dedup_map.values())
# Step 3: 按 layer 排序(L1 > L2 > L3layer 内按 confidence DESC
layer_priority = {"L1": 1, "L2": 2, "L3": 3, "A": 0} # A 优先级最高
deduped.sort(key=lambda r: (
layer_priority.get(r.layer, 99),
-r.confidence if r.confidence else 0
))
# Step 4: 截断到上限
return deduped[:max_count]
```
### 6.2 排序算法
`layer` 字段优先级 + `confidence` 字段排序(同 layer 内按 confidence DESC
### 6.3 上限控制
`max_count=3`PRD §4.7.3 上限规则)
### 6.4 同源抑制(30 分钟窗口)
**代码位置**`asset_recommend_service.py` 新增 `_same_source_suppress` 方法
```python
def _same_source_suppress(
self,
employee_id: str,
keyword: str,
suppress_window_minutes: int = 30
) -> bool:
"""v1.0 新增:同源抑制(PRD §4.7.3 同源抑制规则)"""
cache_key = f"suppress:{employee_id}:{keyword}"
cached = self.redis.get(cache_key)
if cached:
return True # 在抑制窗口内,跳过
# 设置抑制窗口
self.redis.setex(cache_key, suppress_window_minutes * 60, "1")
return False
```
---
## 七、冷启动策略实现(PRD §4.7.4 决策 ① A
### 7.1 客户端状态机
**新增 Pinia store**`src/frontend-h5/src/stores/recommendStore.ts`
```typescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface RecommendCard {
id: string
layer: string // 'A' | 'L1' | 'L2' | 'L3' | 'progress'
source: string
title: string
description?: string
icon?: string
items?: any[]
action_url?: string
action_label?: string
confidence?: number
created_at: string // ISO 8601
ttl_days?: number // 跨会话持久化 TTL
}
export const useRecommendStore = defineStore('recommend', () => {
const cards = ref<RecommendCard[]>([])
const lastUpdate = ref<string>('')
// 状态机
const state = ref<'empty' | 'cold_start' | 'populated' | 'overflow'>('empty')
// 计算属性
const visibleCards = computed(() => {
// PRD §4.7.4 决策 ① A:冷启动时右侧栏空(不显示任何卡片)
if (state.value === 'cold_start' || state.value === 'empty') {
return []
}
return cards.value.slice(0, 3) // §4.7.3 上限 3 张
})
const hasContent = computed(() => visibleCards.value.length > 0)
// Actions
function pushCards(newCards: RecommendCard[]) {
if (newCards.length === 0) {
state.value = state.value === 'empty' ? 'empty' : 'cold_start'
return
}
// FIFO:新卡片插入最上方
cards.value = [...newCards, ...cards.value].slice(0, 10) // 本地缓存上限 10
state.value = 'populated'
lastUpdate.value = new Date().toISOString()
persistToLocalStorage()
}
function updateProgress(recommendId: string, updates: Partial<RecommendCard>) {
const idx = cards.value.findIndex(c => c.id === recommendId)
if (idx === -1) {
// 进度卡不存在 → 插入新卡到顶部
cards.value.unshift({
id: recommendId,
layer: 'progress',
source: 'approval_progress',
...updates,
created_at: new Date().toISOString()
} as RecommendCard)
} else {
cards.value[idx] = { ...cards.value[idx], ...updates }
}
state.value = 'populated'
persistToLocalStorage()
}
function clearByLayer(layers: string[]) {
cards.value = cards.value.filter(c => !layers.includes(c.layer))
if (cards.value.length === 0) {
state.value = 'empty'
}
persistToLocalStorage()
}
function clearAll() {
cards.value = []
state.value = 'empty'
persistToLocalStorage()
}
// 持久化(PRD §4.7.7 决策 ⑤)
function persistToLocalStorage() {
const persistable = cards.value.filter(c =>
['L2', 'L3', 'progress'].includes(c.layer)
)
localStorage.setItem(
`recommend_cards_${getEmployeeId()}`,
JSON.stringify(persistable)
)
}
function loadFromLocalStorage() {
const cached = localStorage.getItem(`recommend_cards_${getEmployeeId()}`)
if (cached) {
try {
cards.value = JSON.parse(cached)
state.value = cards.value.length > 0 ? 'populated' : 'empty'
} catch (e) {
console.warn('Failed to load recommend cards from localStorage', e)
}
}
}
return {
cards,
state,
visibleCards,
hasContent,
lastUpdate,
pushCards,
updateProgress,
clearByLayer,
clearAll,
loadFromLocalStorage
}
})
```
### 7.2 "无则隐" 原则(前端落地)
**DynamicRecommend.vue 改造**
```vue
<template>
<div v-if="recommendStore.hasContent" class="dynamic-recommend">
<!-- PRD §4.5 UI 规范无标题 -->
<div
v-for="card in recommendStore.visibleCards"
:key="card.id"
class="recommend-card"
:class="`recommend-card--${card.layer.toLowerCase()}`"
>
<!-- 卡片内容根据 type 渲染 -->
<component
:is="getCardComponent(card)"
:card="card"
@dismiss="handleDismiss(card)"
/>
</div>
</div>
<!-- PRD §4.7.4 决策 A冷启动空状态不显示任何内容 -->
</template>
```
---
## 八、触发失败降级实现(PRD §4.7.5 决策 ③ B
### 8.1 降级链路图
```
[A 失败] → [B + C + D] Dify action 空 → 走关键词/画像/角色)
[B 失败] → [C + D] (关键词无匹配 → 走画像/角色)
[C 失败] → [D] (画像拿不到 → 走角色)★ 关键降级
[D 失败] → [] (角色无匹配 → 空)★ 关键
[全部失败] → [] (按 §4.7.4 走空状态)
```
### 8.2 实现(已在 §4.3 / §4.4 / §5.2 给出代码)
---
## 九、话题切换检测实现(PRD §4.7.6 决策 ④ B
### 9.1 Jaccard 相似度算法
**新增工具**`src/backend/app/services/topic_detector.py`
```python
def jaccard_similarity(set1: set, set2: set) -> float:
"""Jaccard 相似度"""
if not set1 and not set2:
return 1.0 # 两个空集合相似度为 1
intersection = set1 & set2
union = set1 | set2
return len(intersection) / len(union) if union else 0.0
def detect_topic_change(recent_messages: List[str], current_message: str, threshold: float = 0.3) -> bool:
"""检测话题切换(PRD §4.7.6"""
current_keywords = extract_keywords(current_message)
recent_keywords = set()
for msg in recent_messages[-3:]: # 最近 3 条
recent_keywords |= extract_keywords(msg)
similarity = jaccard_similarity(current_keywords, recent_keywords)
return similarity < threshold
```
### 9.2 意图突变检测
由 Dify 返回的 `intent_type` 与上一条不同时触发清空(已在 `match_profile_triggers` 中处理)
### 9.3 会话分隔
用户点"结束会话"或新建会话时清空所有跨会话推荐
---
## 十、跨会话持久化实现(PRD §4.7.7 决策 ⑤ B
### 10.1 客户端 localStorage 方案
已在 §7.1 `recommendStore.ts::persistToLocalStorage` 实现
### 10.2 5 类推荐的差异化持久化
| 推荐类型 | 持久化 | 存储位置 | 过期规则 |
|---------|--------|---------|---------|
| L1 / A | ❌ 不持久 | 仅 Pinia state | 话题切换即清 |
| L2 | ✅ 持久 30 天 | localStorage | 30 天后清 / 画像更新时清 |
| L3 | ✅ 持久 | localStorage | 角色变更时清 |
| 审批进度(T2) | ✅ 持久直到完结 | localStorage | 审批结束/已读时清 |
---
## 十一、数据模型
### 11.1 新增 `recommend_progress` 表(审批进度)
```sql
CREATE TABLE recommend_progress (
id SERIAL PRIMARY KEY,
recommend_id VARCHAR(64) UNIQUE NOT NULL, -- rec_{approval_id}
employee_id VARCHAR(64) NOT NULL,
approval_id VARCHAR(64) NOT NULL,
approval_type VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL, -- pending/approved/rejected/completed
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
ttl_days INT DEFAULT 7, -- 完结后 N 天清除
INDEX idx_employee (employee_id),
INDEX idx_approval (approval_id)
);
```
### 11.2 新增 `recommend_event` 表(埋点)
```sql
CREATE TABLE recommend_event (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(64) NOT NULL,
recommend_id VARCHAR(64),
layer VARCHAR(10), -- A/L1/L2/L3/progress
source VARCHAR(50),
event_type VARCHAR(20) NOT NULL, -- shown/clicked/dismissed/used
conversation_id VARCHAR(64),
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_employee_event (employee_id, event_type),
INDEX idx_time (created_at)
);
```
### 11.3 Alembic 迁移
新增 `alembic/versions/{revision}_add_recommend_progress.py`(按项目惯例生成)
---
## 十二、接口设计
### 12.1 WebSocket 协议扩展
| type | 来源 | 触发时机 | 数据结构 |
|------|------|---------|---------|
| `dynamic_recommend` | A | T0 | `{source, trigger_timing, layer: "A", recommend_id, card_type, title, ...}` |
| `asset_recommend` | B/C/D | T1 | `{recommends: [{layer, source, title, confidence, ...}], layered: true}` |
| `recommend_update` | T2 | 事件 | `{recommend_id, source: "approval_progress", status, updated_at, ttl_days}` |
### 12.2 新增 REST API
| 方法 | 路径 | 用途 |
|------|------|------|
| GET | `/api/recommend/progress/{approval_id}` | 查审批进度(前端轮询兜底) |
| POST | `/api/recommend/feedback` | 用户"不相关"反馈(v1.2 接入) |
| POST | `/api/webhook/wecom-approval` | 企微审批 webhook 接收 |
---
## 十三、前端改造
### 13.1 DynamicRecommend.vue 重构
详见 §7.2 模板代码
### 13.2 RightPanel.vue 适配
**改造点**
- 移除"智能推荐"标题(PRD §4.5 UI 规范:无标题)
- 引用 `useRecommendStore()` 替代本地状态
- 监听 store.state 决定是否显示区块
### 13.3 状态机 store
详见 §7.1
---
## 十四、性能与可用性
### 14.1 性能指标
| 指标 | 目标 | 测量 |
|------|------|------|
| T1 异步触发响应时间 | < 3s | 后端日志 |
| 右侧栏渲染时间 | < 200ms | 浏览器 Performance |
| WS 推送零延迟(T0 | 0ms | 网络抓包 |
| localStorage 读写 | < 10ms | 浏览器 Performance |
### 14.2 WS 断连降级
WS 断连时,store 保持现有 cards(不重置),重连后增量同步
---
## 十五、风险与降级
| 风险 | 等级 | 降级策略 |
|------|------|---------|
| Dify action 频繁失败 | 高 | A 来源降级为 B/C/D(不影响主对话) |
| 画像 API 不可用 | 中 | C 来源降级为 D(用缓存画像兜底) |
| WS 断连 | 中 | localStorage 保持现有卡片,重连后增量 |
| localStorage 配额超限 | 低 | 仅保留 L2/L3/progressLRU 淘汰 |
| 审批 webhook 丢失 | 中 | 60s 轮询兜底 |
---
## 十六、部署与灰度
### 16.1 部署顺序
1. **后端**`asset_recommend_service.py` + `recommend_progress_service.py` + 新增表 + Alembic 迁移
2. **前端 H5**`recommendStore.ts` + `DynamicRecommend.vue` 重构 + `RightPanel.vue` 适配
3. **nginx**:无改动
4. **DB 迁移**`alembic upgrade head`(蓝绿环境)
5. **验收**:端到端 + 数据埋点
### 16.2 灰度策略
| 阶段 | 范围 | 持续时间 | 验证指标 |
|------|------|---------|---------|
| 1% 灰度 | 10 人 | 1 天 | 无 P0/P1 错误 |
| 10% 灰度 | 100 人 | 2 天 | 点击率 > 5% |
| 50% 灰度 | 500 人 | 3 天 | 点击率 > 10% |
| 100% 全量 | 全部员工 | - | 点击率 > 15% |
### 16.3 回滚预案
**触发条件**
- P0/P1 错误率 > 5%
- 推荐卡片渲染失败 > 2%
- WS 推送延迟 > 5s
**回滚操作**
- 后端:`docker compose restart backend`(保留 DB 数据)
- 前端:`docker restart wecom_it_nginx`
- DB`alembic downgrade -1`(删除 recommend_progress + recommend_event 表)
---
## 十七、测试策略
### 17.1 单元测试
| 模块 | 测试用例数 | 覆盖目标 |
|------|----------|---------|
| `asset_recommend_service.py` | 30+ | L1/L2/L3 匹配 + 合并 + 降级 |
| `recommend_progress_service.py` | 10+ | 审批进度回流 + 轮询 |
| `topic_detector.py` | 15+ | Jaccard 相似度 + 阈值边界 |
### 17.2 集成测试
- WS 协议全链路(A + B + C + D + T2
- 多源合并(4 类来源同帧推送)
- 持久化(localStorage 跨会话)
### 17.3 E2E 测试(agent-browser
- 员工发"VPN 申请" → 右侧栏出 VPN 卡 + 进度回流
- 员工切换话题 → 旧 L1 推荐清空
- 用户关闭浏览器再打开 → L2/L3/progress 持久卡片仍在
### 17.4 A/B 测试
- 灰度期间对比新旧两版的点击率、自助解决率
---
## 十八、关联文档
- **PRD(冻结)**`docs/01-产品文档/05-用户端H5/PRD-REQ-用户-006-智能推荐重构-v1.0-Frozen.md`
- **改造实施计划**`docs/02-技术文档/实现配置/AI对话链路全栈改造实施计划-v1.0.md`
- **H5 用户端实现概览**`docs/02-技术文档/前端改造/设计-H5用户端实现概览-v1.0.md`
- **右侧栏动态推送评估**`docs/02-技术文档/前端改造/前端设计-H5右侧栏动态推送-v1.0.md`
- **AI 回复来源标识 PRD**`docs/01-产品文档/03-AI服务/PRD-REQ-AI-004-AI回复来源标识-v1.0.md`
- **代码**
- `src/backend/app/services/asset_recommend_service.py`
- `src/backend/app/services/recommend_progress_service.py`v1.0 新增)
- `src/backend/app/services/topic_detector.py`v1.0 新增)
- `src/backend/app/config/assets.yaml`
- `src/backend/app/tasks/h5_ai_task.py`
- `src/frontend-h5/src/stores/recommendStore.ts`v1.0 新增)
- `src/frontend-h5/src/components/assistant/DynamicRecommend.vue`
- `src/frontend-h5/src/components/assistant/RightPanel.vue`
---
## 十九、变更日志
| 版本 | 日期 | 变更内容 | 作者 |
|------|------|---------|------|
| v1.0 | 2026-07-28 19:20 | 初版:基于 PRD §4.7 冻结决策,给出 4 类触发源 + 3 种时机 + 多源合并 + 冷启动 + 降级 + 话题切换 + 持久化 的技术实现 | Duckula + 宋献 |