1023 lines
32 KiB
Markdown
1023 lines
32 KiB
Markdown
|
|
# 技术方案 - 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 通道 | 触发时机 | 现状问题 |
|
|||
|
|
|------|---------|---------|---------|---------|
|
|||
|
|
| **A:Dify action** | `h5_ai_task.py::_step_persist` line 595 | `dynamic_recommend` WS | T0 同步 | v2.3 审批类只推左边 |
|
|||
|
|
| **B:L1 关键词** | `_push_asset_recommends` → `asset_service.match_keywords()` | `asset_recommend` WS | T1 异步 | `if keyword in message_lower` 太硬,无语义 |
|
|||
|
|
| **C:L2 画像** | `_push_asset_recommends` → `asset_service.match_profile_triggers()` | `asset_recommend` WS | T1 异步 | 画像 API 拿不到时全部 if 跳过 |
|
|||
|
|
| **D:L3 角色** | `_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 来源 A:Dify action(T0 同步)
|
|||
|
|
|
|||
|
|
**代码位置**:`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` 字段 | P1(v1.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 来源 C:L2 画像触发(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 来源 D:L3 角色匹配(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 # 推荐用稳定 ID;fallback 到 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 > L3,layer 内按 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/progress,LRU 淘汰 |
|
|||
|
|
| 审批 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 + 宋献 |
|