# 技术方案 - 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
右侧栏 UI] DR[DynamicRecommend.vue
推荐卡片渲染] RS[recommendStore.ts
Pinia 状态机] LS[(localStorage
持久化)] end subgraph "后端" WS[ws_manager.py
WebSocket 管理] T1[h5_ai_task.py
_step_persist T0] T2[h5_ai_task.py
_step_assets T1] T3[recommend_event_listener.py
T2 webhook/轮询] AS[asset_recommend_service.py
4 类触发源核心] AR[assets.yaml
L1+L3 配置] EP[employee_profile_service.py
L2 画像] DB[(PostgreSQL
recommend_progress)] end subgraph "外部" DIFY[Dify 主推理
itdesk_main] WECOM[企微审批
webhook] HUORONG[火绒 API
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 非空且非审批类
但 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([]) const lastUpdate = ref('') // 状态机 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) { 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 ``` --- ## 八、触发失败降级实现(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 + 宋献 |