本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交 (5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。 一、docs 结构整改(整改 #14) 根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入, 随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录 树并存于 docs/,共 791 文件、双分类体系冲突。 修复动作: - b2 同名异主题文件改名迁移保全 9 个 - C 类 39 个孤立文件按主题正确归类 - A/B1 类 222 个重复文件删除(新结构已有内容副本) - 9 个旧独有空目录删除 - 270 处内部引用按 verified 映射改写 - 整改记录 #14 登记于 04-运维文档/部署运维 结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。 残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。 二、compose 双目录对齐(消除踩坑 A) - docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist 改为 src/frontend-*/dist(h5 / agent / admin / terminal) - docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/ - 效果:本地 docker compose up 不再把根目录 stale dist 挂回, 与线上一致,分叉隐患消除(已 docker compose config 校验通过) 防复发铁律: - 重构须提交;仓库修复须 git stash -u 或先 commit - 新结构须 git add 并提交,避免再次 untracked 复活 - H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
32 KiB
技术方案 - 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 对齐)
- L1 关键词匹配太硬 → 易误命中("邮箱配置"也命中"邮箱")
- L2 画像依赖外部 API → 拿不到时空 L2
- L3 角色推荐不工作 → 中文/英文 key 不匹配
- 双轨运行(A/B/C/D 各跑各的)→ 无去重、无排序
- 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 整体架构图
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 申请"全链路
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 现状(审批类只推左边),仅优化非审批类的来源标记
# 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 实现(最小改动版):
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):
# 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:
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 触发逻辑保持不变
...
新增降级函数:
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:
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):
# 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
改动点:
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
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 方法
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 方法
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
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 改造:
<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
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 表(审批进度)
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 表(埋点)
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 部署顺序
- 后端:
asset_recommend_service.py+recommend_progress_service.py+ 新增表 + Alembic 迁移 - 前端 H5:
recommendStore.ts+DynamicRecommend.vue重构 +RightPanel.vue适配 - nginx:无改动
- DB 迁移:
alembic upgrade head(蓝绿环境) - 验收:端到端 + 数据埋点
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.pysrc/backend/app/services/recommend_progress_service.py(v1.0 新增)src/backend/app/services/topic_detector.py(v1.0 新增)src/backend/app/config/assets.yamlsrc/backend/app/tasks/h5_ai_task.pysrc/frontend-h5/src/stores/recommendStore.ts(v1.0 新增)src/frontend-h5/src/components/assistant/DynamicRecommend.vuesrc/frontend-h5/src/components/assistant/RightPanel.vue
十九、变更日志
| 版本 | 日期 | 变更内容 | 作者 |
|---|---|---|---|
| v1.0 | 2026-07-28 19:20 | 初版:基于 PRD §4.7 冻结决策,给出 4 类触发源 + 3 种时机 + 多源合并 + 冷启动 + 降级 + 话题切换 + 持久化 的技术实现 | Duckula + 宋献 |