Files
wecom_it_smart_desk/docs/02-技术文档/技术架构/技术方案-REQ-用户-006-智能推荐重构-v1.0.md
T
Simon facc04aa65 chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .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-*/
2026-08-07 22:31:32 +08:00

32 KiB
Raw Blame 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_recommendsasset_service.match_keywords() asset_recommend WS T1 异步 if keyword in message_lower 太硬,无语义
CL2 画像 _push_asset_recommendsasset_service.match_profile_triggers() asset_recommend WS T1 异步 画像 API 拿不到时全部 if 跳过
DL3 角色 _push_asset_recommendsasset_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.tsPinia 状态机管理)

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 来源 ADify actionT0 同步)

代码位置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 库,对用户消息分词后再匹配 P1v1.2 考虑)
4.2.2 同义词表(推荐) assets.yaml 新增 synonyms 字段 P1v1.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  # 推荐用稳定 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=3PRD §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 storesrc/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/progressLRU 淘汰
审批 webhook 丢失 60s 轮询兜底

十六、部署与灰度

16.1 部署顺序

  1. 后端asset_recommend_service.py + recommend_progress_service.py + 新增表 + Alembic 迁移
  2. 前端 H5recommendStore.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
  • DBalembic 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 回复来源标识 PRDdocs/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.pyv1.0 新增)
    • src/backend/app/services/topic_detector.pyv1.0 新增)
    • src/backend/app/config/assets.yaml
    • src/backend/app/tasks/h5_ai_task.py
    • src/frontend-h5/src/stores/recommendStore.tsv1.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 + 宋献