v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化
This commit is contained in:
@@ -1489,6 +1489,7 @@ async def list_audit_conversations(
|
||||
keyword: Optional[str] = None,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
is_archived: Optional[bool] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -1521,6 +1522,8 @@ async def list_audit_conversations(
|
||||
filters.append(Conversation.created_at < dt_to)
|
||||
except ValueError:
|
||||
pass
|
||||
if is_archived is not None:
|
||||
filters.append(Conversation.is_archived == is_archived)
|
||||
|
||||
if filters:
|
||||
stmt = stmt.where(and_(*filters))
|
||||
|
||||
@@ -458,14 +458,26 @@ class AIService:
|
||||
if parsed:
|
||||
# JSON 解析成功
|
||||
text = parsed.get("text", "")
|
||||
# ★ 防御性类型保护:Dify 可能返回 text 为对象/列表而非字符串
|
||||
# 当 text 为 dict/list 时,直接传给前端会显示 [object Object]
|
||||
if not isinstance(text, str):
|
||||
text = json.dumps(text, ensure_ascii=False) if text else ""
|
||||
logger.warning(f"Dify 返回 text 非 String 类型,已转换为 JSON 字符串: {text[:80]}...")
|
||||
action = parsed.get("action")
|
||||
options = parsed.get("options")
|
||||
diagnosis_stage = parsed.get("diagnosis_stage")
|
||||
|
||||
# ★ 调试日志:打印 action 对象的详细内容
|
||||
logger.info(f"[DEBUG] Dify action 对象: {action}")
|
||||
if action:
|
||||
logger.info(f"[DEBUG] action.approval_type = {action.get('approval_type')}")
|
||||
|
||||
hit = self._check_knowledge_hit(text) if text else False
|
||||
|
||||
logger.info(
|
||||
f"Dify 原生 structured 返回: hit={hit}, "
|
||||
f"text_len={len(text)}, "
|
||||
f"text_preview={text[:60]}, "
|
||||
f"has_action={action is not None}, "
|
||||
f"has_options={options is not None}, "
|
||||
f"diagnosis_stage={diagnosis_stage}, "
|
||||
@@ -556,6 +568,10 @@ class AIService:
|
||||
if parsed:
|
||||
# JSON 解析成功
|
||||
text = parsed.get("text", "")
|
||||
# ★ 防御性类型保护(与原生路径一致)
|
||||
if not isinstance(text, str):
|
||||
text = json.dumps(text, ensure_ascii=False) if text else ""
|
||||
logger.warning(f"Dify 代理返回 text 非 String 类型,已转换: {text[:80]}...")
|
||||
action = parsed.get("action")
|
||||
options = parsed.get("options")
|
||||
# Phase 6A: 提取诊断阶段(diagnosis_stage)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# =============================================================================
|
||||
# IT智能服务台 — 审批卡片匹配引擎
|
||||
# =============================================================================
|
||||
# 职责:统一审批匹配逻辑,替代前端 APPROVAL_OPTIONS 和后端分散的匹配代码
|
||||
#
|
||||
# 匹配优先级:
|
||||
# 1. approval_type 精确匹配模板 ID(如 "zero_trust_vpn")
|
||||
# 2. approval_type 关键词匹配模板 keywords(如 "VPN账号申请" 含 "VPN")
|
||||
# 3. approval_type 匹配 category(如 "账号权限申请" → 返回该分类下所有模板)
|
||||
# 4. title 匹配模板 name(如 "VPN账号申请" → zero_trust_vpn)
|
||||
# 5. 降级:用户原始文本遍历所有 keywords
|
||||
#
|
||||
# 输出:标准化卡片数据 card_data,前端纯渲染
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from app.api.approval import APPROVAL_TEMPLATES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApprovalMatcher:
|
||||
"""审批卡片匹配器 — 后端统一匹配入口"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公开方法
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def match_and_build_card(
|
||||
self, approval_type: Optional[str], title: Optional[str] = None
|
||||
) -> Optional[dict]:
|
||||
"""一站式匹配 + 构建标准化卡片数据(Dify 正常路径)。
|
||||
|
||||
Args:
|
||||
approval_type: Dify 返回的审批类型(可能是 ID、中文分类名或具体名称)
|
||||
title: Dify 返回的具体审批名称(如"VPN账号申请")
|
||||
|
||||
Returns:
|
||||
标准化 card_data,或 None(匹配失败)
|
||||
"""
|
||||
if not approval_type:
|
||||
return None
|
||||
|
||||
# 优先级 1:精确 ID 匹配
|
||||
template = self._match_by_id(approval_type)
|
||||
if template:
|
||||
return self._build_single_card(template)
|
||||
|
||||
# 优先级 2:关键词匹配
|
||||
template = self._match_by_keyword(approval_type)
|
||||
if template:
|
||||
return self._build_single_card(template)
|
||||
|
||||
# 优先级 3:category 匹配
|
||||
options = self._match_by_category(approval_type)
|
||||
if options:
|
||||
return self._build_multi_card(approval_type, options)
|
||||
|
||||
# 优先级 4:title 匹配
|
||||
if title:
|
||||
template = self._match_by_name(title)
|
||||
if template:
|
||||
return self._build_single_card(template)
|
||||
|
||||
# 优先级 5:文本 keywords 兜底
|
||||
template = self._match_by_keyword(approval_type)
|
||||
if template:
|
||||
return self._build_single_card(template)
|
||||
|
||||
return None
|
||||
|
||||
def match_by_keywords(self, user_text: str) -> Optional[dict]:
|
||||
"""Dify 不可用时,用用户原始文本关键词降级匹配。
|
||||
|
||||
Args:
|
||||
user_text: 用户输入的原始文本
|
||||
|
||||
Returns:
|
||||
标准化 card_data,或 None
|
||||
"""
|
||||
if not user_text:
|
||||
return None
|
||||
|
||||
text_lower = user_text.lower()
|
||||
|
||||
# 遍历所有模板的 keywords
|
||||
best_template = None
|
||||
best_score = 0
|
||||
|
||||
for template_id, template in APPROVAL_TEMPLATES.items():
|
||||
keywords = template.get("keywords", [])
|
||||
score = 0
|
||||
for kw in keywords:
|
||||
if kw.lower() in text_lower:
|
||||
score += len(kw) # 关键词越长,匹配越准确
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_template = template
|
||||
|
||||
if best_template and best_score > 0:
|
||||
logger.info(
|
||||
f"[ApprovalMatcher] 关键词降级匹配: text={user_text[:30]} -> "
|
||||
f"template={best_template['id']}, score={best_score}"
|
||||
)
|
||||
return self._build_single_card(best_template)
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部匹配方法
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _match_by_id(self, approval_type: str) -> Optional[dict]:
|
||||
"""精确匹配模板 ID(如 "zero_trust_vpn")。"""
|
||||
return APPROVAL_TEMPLATES.get(approval_type)
|
||||
|
||||
def _match_by_keyword(self, approval_type: str) -> Optional[dict]:
|
||||
"""通过关键词匹配模板。
|
||||
|
||||
遍历所有模板的 keywords,检查 approval_type 是否包含任一关键词。
|
||||
使用最长匹配优先策略(避免"设备升级"误匹配到"升级")。
|
||||
"""
|
||||
approval_lower = approval_type.lower()
|
||||
best_template = None
|
||||
best_len = 0
|
||||
|
||||
for template_id, template in APPROVAL_TEMPLATES.items():
|
||||
keywords = template.get("keywords", [])
|
||||
for kw in keywords:
|
||||
if kw.lower() in approval_lower:
|
||||
if len(kw) > best_len:
|
||||
best_len = len(kw)
|
||||
best_template = template
|
||||
|
||||
return best_template
|
||||
|
||||
def _match_by_category(self, approval_type: str) -> list[dict]:
|
||||
"""按 category 匹配,返回该分类下所有模板的卡片选项。"""
|
||||
result = []
|
||||
for template_id, template in APPROVAL_TEMPLATES.items():
|
||||
category = template.get("category", "")
|
||||
if category == approval_type:
|
||||
result.append(self._template_to_option(template))
|
||||
return result
|
||||
|
||||
def _match_by_name(self, title: str) -> Optional[dict]:
|
||||
"""通过模板 name 精确/模糊匹配(空格容错)。"""
|
||||
normalized = title.replace(" ", "") # 去除空格容错
|
||||
|
||||
for template_id, template in APPROVAL_TEMPLATES.items():
|
||||
name_clean = template["name"].replace(" ", "")
|
||||
if name_clean == normalized or normalized in name_clean:
|
||||
return template
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 卡片数据构建
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_single_card(self, template: dict) -> dict:
|
||||
"""构建单选项标准化卡片数据。"""
|
||||
return {
|
||||
"card_type": "single",
|
||||
"title": template["name"],
|
||||
"description": template.get("desc", ""),
|
||||
"options": [self._template_to_option(template)],
|
||||
}
|
||||
|
||||
def _build_multi_card(self, category_name: str, options: list[dict]) -> dict:
|
||||
"""构建多选项标准化卡片数据。"""
|
||||
return {
|
||||
"card_type": "multiple",
|
||||
"title": f"{category_name}({len(options)}项)",
|
||||
"description": "请选择具体审批类型",
|
||||
"options": options,
|
||||
}
|
||||
|
||||
def _template_to_option(self, template: dict) -> dict:
|
||||
"""将模板数据转换为前端卡片选项。"""
|
||||
return {
|
||||
"name": template["name"],
|
||||
"icon": template.get("icon", "orders-o"),
|
||||
"desc": template.get("desc", ""),
|
||||
"url": template.get("url", ""),
|
||||
"category": template.get("category", ""),
|
||||
}
|
||||
|
||||
def get_all_categories(self) -> list[dict]:
|
||||
"""获取所有分类及其选项(供前端全部展示用)。"""
|
||||
categories = {}
|
||||
for template_id, template in APPROVAL_TEMPLATES.items():
|
||||
cat = template.get("category", "其他")
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append(self._template_to_option(template))
|
||||
|
||||
result = []
|
||||
for cat, options in categories.items():
|
||||
result.append({
|
||||
"category": cat,
|
||||
"options": options,
|
||||
})
|
||||
return result
|
||||
|
||||
def get_all_templates(self) -> list[dict]:
|
||||
"""获取所有模板(含新增字段)。"""
|
||||
return [
|
||||
{**template, "template_id": tid}
|
||||
for tid, template in APPROVAL_TEMPLATES.items()
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 单例工厂
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_approval_matcher: Optional[ApprovalMatcher] = None
|
||||
|
||||
|
||||
def get_approval_matcher() -> ApprovalMatcher:
|
||||
"""获取 ApprovalMatcher 单例。"""
|
||||
global _approval_matcher
|
||||
if _approval_matcher is None:
|
||||
_approval_matcher = ApprovalMatcher()
|
||||
return _approval_matcher
|
||||
@@ -0,0 +1,430 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
资产推荐服务 (Asset Recommend Service)
|
||||
|
||||
用于右侧栏智能推荐的资产信息管理:
|
||||
- 关键词匹配资产(L1)
|
||||
- 画像触发运维提醒(L2)
|
||||
- 角色通用资源(L3)
|
||||
|
||||
支持 YAML 配置文件热重载
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecommendItem:
|
||||
"""推荐操作项"""
|
||||
label: str
|
||||
type: str # download, approval, info, doc, guide, link, contact
|
||||
url: Optional[str] = None
|
||||
value: Optional[str] = None
|
||||
copyable: Optional[bool] = False
|
||||
description: Optional[str] = None
|
||||
approval_type: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecommendCard:
|
||||
"""推荐卡片"""
|
||||
id: str
|
||||
layer: str # L1, L2, L3
|
||||
layer_label: str
|
||||
source: str # dify_intent, keyword_assets, profile_trigger, role_assets
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
items: Optional[List[Dict]] = None
|
||||
action_url: Optional[str] = None
|
||||
action_label: Optional[str] = None
|
||||
confidence: Optional[float] = None
|
||||
relevance: str = "high" # high, low
|
||||
priority: int = 50
|
||||
extra: Optional[Dict] = None
|
||||
|
||||
|
||||
class AssetRecommendService:
|
||||
"""
|
||||
资产推荐服务
|
||||
|
||||
功能:
|
||||
1. 从 YAML 配置文件加载资产数据
|
||||
2. 关键词匹配查询
|
||||
3. 画像触发规则匹配
|
||||
4. 角色资源查询
|
||||
5. 配置热重载
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""单例模式"""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, config_path: str = None):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
# 配置路径
|
||||
self.config_path = config_path or os.environ.get(
|
||||
'ASSETS_CONFIG_PATH',
|
||||
str(Path(__file__).parent.parent / 'config' / 'assets.yaml')
|
||||
)
|
||||
|
||||
# 数据存储
|
||||
self.keyword_assets: Dict[str, Dict] = {}
|
||||
self.role_assets: Dict[str, Dict] = {}
|
||||
self._alias_map: Dict[str, str] = {}
|
||||
|
||||
# 加载配置
|
||||
self.load()
|
||||
|
||||
logger.info(f"[AssetRecommend] 服务初始化完成,配置路径: {self.config_path}")
|
||||
|
||||
def load(self) -> None:
|
||||
"""加载配置文件"""
|
||||
try:
|
||||
if not os.path.exists(self.config_path):
|
||||
logger.warning(f"[AssetRecommend] 配置文件不存在: {self.config_path}")
|
||||
return
|
||||
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
if not config:
|
||||
logger.warning("[AssetRecommend] 配置文件为空")
|
||||
return
|
||||
|
||||
# 加载关键词资产
|
||||
keyword_assets = config.get('keyword_assets', {})
|
||||
self._build_keyword_assets(keyword_assets)
|
||||
|
||||
# 加载角色资产
|
||||
self.role_assets = config.get('role_assets', {})
|
||||
|
||||
logger.info(
|
||||
f"[AssetRecommend] 配置加载完成: "
|
||||
f"关键词资产 {len(self.keyword_assets)} 个, "
|
||||
f"角色资产 {len(self.role_assets)} 个"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AssetRecommend] 配置加载失败: {e}")
|
||||
|
||||
def _build_keyword_assets(self, keyword_assets: Dict) -> None:
|
||||
"""构建关键词资产映射,处理别名"""
|
||||
self.keyword_assets = {}
|
||||
self._alias_map = {}
|
||||
|
||||
for keyword, asset in keyword_assets.items():
|
||||
# 处理别名
|
||||
if 'alias_of' in asset:
|
||||
self._alias_map[keyword] = asset['alias_of']
|
||||
continue
|
||||
|
||||
# 存储资产
|
||||
self.keyword_assets[keyword] = asset
|
||||
|
||||
def reload(self) -> None:
|
||||
"""热重载配置"""
|
||||
logger.info("[AssetRecommend] 开始热重载配置")
|
||||
self.load()
|
||||
logger.info("[AssetRecommend] 配置热重载完成")
|
||||
|
||||
def get_by_keyword(self, keyword: str) -> Optional[Dict]:
|
||||
"""
|
||||
根据关键词查询资产
|
||||
|
||||
Args:
|
||||
keyword: 关键词(如 "vpn"、"打印机")
|
||||
|
||||
Returns:
|
||||
资产信息字典,未找到返回 None
|
||||
"""
|
||||
# 查别名
|
||||
keyword = self._alias_map.get(keyword, keyword)
|
||||
|
||||
# 查资产
|
||||
asset = self.keyword_assets.get(keyword)
|
||||
|
||||
if asset:
|
||||
logger.debug(f"[AssetRecommend] 命中关键词: {keyword}")
|
||||
|
||||
return asset
|
||||
|
||||
def match_keywords(self, message: str) -> List[RecommendCard]:
|
||||
"""
|
||||
从消息中匹配关键词,返回推荐卡片列表
|
||||
|
||||
Args:
|
||||
message: 用户消息
|
||||
|
||||
Returns:
|
||||
推荐卡片列表
|
||||
"""
|
||||
cards = []
|
||||
message_lower = message.lower()
|
||||
|
||||
for keyword, asset in self.keyword_assets.items():
|
||||
if keyword in message_lower:
|
||||
card = self._build_card_from_asset(keyword, asset, source='keyword_assets')
|
||||
cards.append(card)
|
||||
logger.debug(f"[AssetRecommend] 消息匹配关键词: {keyword}")
|
||||
|
||||
return cards
|
||||
|
||||
def get_by_role(self, role: str) -> List[RecommendCard]:
|
||||
"""
|
||||
根据角色获取推荐卡片
|
||||
|
||||
Args:
|
||||
role: 角色标识(如 "new_employee"、"developer")
|
||||
|
||||
Returns:
|
||||
推荐卡片列表
|
||||
"""
|
||||
cards = []
|
||||
|
||||
# 精确匹配
|
||||
if role in self.role_assets:
|
||||
asset = self.role_assets[role]
|
||||
card = self._build_card_from_asset(role, asset, source='role_assets')
|
||||
cards.append(card)
|
||||
|
||||
# 模糊匹配(角色名包含)
|
||||
for role_key, asset in self.role_assets.items():
|
||||
if role_key in role or role in role_key:
|
||||
if role_key != role: # 避免重复
|
||||
card = self._build_card_from_asset(role_key, asset, source='role_assets')
|
||||
cards.append(card)
|
||||
|
||||
return cards
|
||||
|
||||
def _build_card_from_asset(
|
||||
self,
|
||||
keyword: str,
|
||||
asset: Dict,
|
||||
source: str,
|
||||
layer: str = 'L1',
|
||||
layer_label: str = '相关推荐'
|
||||
) -> RecommendCard:
|
||||
"""从资产数据构建推荐卡片"""
|
||||
|
||||
import uuid
|
||||
|
||||
return RecommendCard(
|
||||
id=f"rec_{uuid.uuid4().hex[:8]}",
|
||||
layer=layer,
|
||||
layer_label=layer_label,
|
||||
source=source,
|
||||
title=asset.get('title', ''),
|
||||
description=asset.get('description'),
|
||||
icon=asset.get('icon'),
|
||||
items=asset.get('items', []),
|
||||
relevance='high' if layer == 'L1' else 'low',
|
||||
priority=80 if layer == 'L1' else 50
|
||||
)
|
||||
|
||||
def build_ws_message(self, recommends: List[RecommendCard]) -> Dict:
|
||||
"""
|
||||
构建 WebSocket 推送消息
|
||||
|
||||
Args:
|
||||
recommends: 推荐卡片列表
|
||||
|
||||
Returns:
|
||||
WS 消息字典
|
||||
"""
|
||||
return {
|
||||
'type': 'asset_recommend',
|
||||
'data': {
|
||||
'recommends': [asdict(rec) for rec in recommends],
|
||||
'layered': True
|
||||
}
|
||||
}
|
||||
|
||||
def match_profile_triggers(self, profile: Dict) -> List[RecommendCard]:
|
||||
"""
|
||||
画像触发规则匹配
|
||||
|
||||
Args:
|
||||
profile: 员工画像数据
|
||||
|
||||
Returns:
|
||||
推荐卡片列表(L2)
|
||||
"""
|
||||
cards = []
|
||||
|
||||
# 1. 火绒终端版本过旧
|
||||
huorong_version = profile.get('huorong_version', '')
|
||||
if huorong_version:
|
||||
try:
|
||||
current = float(huorong_version.replace('5.0.', ''))
|
||||
if current < 73: # 假设最新版本是 5.0.73
|
||||
cards.append(RecommendCard(
|
||||
id=f"rec_huorong_version_{datetime.now().strftime('%Y%m%d')}",
|
||||
layer='L2',
|
||||
layer_label='运维提醒',
|
||||
source='profile_trigger',
|
||||
title='火绒终端版本过旧',
|
||||
description=f'您的火绒终端版本为 {huorong_version},最新版本为 5.0.73,建议更新',
|
||||
icon='🔶',
|
||||
action_url='/itportal/resource/huorong-update',
|
||||
relevance='low',
|
||||
priority=90
|
||||
))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# 2. 火绒病毒库过期
|
||||
virusdb_date = profile.get('huorong_virusdb_date')
|
||||
if virusdb_date:
|
||||
try:
|
||||
if isinstance(virusdb_date, str):
|
||||
virusdb_date = datetime.fromisoformat(virusdb_date.replace('Z', '+00:00'))
|
||||
days_since = (datetime.now() - virusdb_date.replace(tzinfo=None)).days
|
||||
if days_since > 7:
|
||||
cards.append(RecommendCard(
|
||||
id=f"rec_virusdb_{datetime.now().strftime('%Y%m%d')}",
|
||||
layer='L2',
|
||||
layer_label='运维提醒',
|
||||
source='profile_trigger',
|
||||
title='火绒病毒库过期',
|
||||
description=f'病毒库已 {days_since} 天未更新,建议立即升级',
|
||||
icon='🦠',
|
||||
action_url='/itportal/resource/huorong-update',
|
||||
relevance='low',
|
||||
priority=80
|
||||
))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# 3. 终端防护离线
|
||||
offline_days = profile.get('huorong_offline_days', 0)
|
||||
if offline_days > 3:
|
||||
cards.append(RecommendCard(
|
||||
id=f"rec_offline_{datetime.now().strftime('%Y%m%d')}",
|
||||
layer='L2',
|
||||
layer_label='运维提醒',
|
||||
source='profile_trigger',
|
||||
title='终端防护离线',
|
||||
description=f'火绒终端已离线 {offline_days} 天,请检查网络连接',
|
||||
icon='📡',
|
||||
action_url='/itportal/resource/huorong-reconnect',
|
||||
relevance='low',
|
||||
priority=100 # 最高优先级
|
||||
))
|
||||
|
||||
# 4. 系统补丁缺失
|
||||
missing_patches = profile.get('unionsoft_patches_missing', 0)
|
||||
if missing_patches > 10:
|
||||
cards.append(RecommendCard(
|
||||
id=f"rec_patches_{datetime.now().strftime('%Y%m%d')}",
|
||||
layer='L2',
|
||||
layer_label='运维提醒',
|
||||
source='profile_trigger',
|
||||
title='系统补丁缺失',
|
||||
description=f'您的电脑有 {missing_patches} 个安全补丁未安装,可能存在安全风险',
|
||||
icon='🔧',
|
||||
action_url='/itportal/resource/windows-update',
|
||||
relevance='low',
|
||||
priority=90
|
||||
))
|
||||
|
||||
# 5. 联软违规项
|
||||
violations = profile.get('unionsoft_violations', [])
|
||||
if violations:
|
||||
cards.append(RecommendCard(
|
||||
id=f"rec_violations_{datetime.now().strftime('%Y%m%d')}",
|
||||
layer='L2',
|
||||
layer_label='运维提醒',
|
||||
source='profile_trigger',
|
||||
title='安全策略违规',
|
||||
description=f'检测到 {len(violations)} 项违规项',
|
||||
icon='⚠️',
|
||||
action_url='/itportal/resource/security-compliance',
|
||||
relevance='low',
|
||||
priority=95,
|
||||
extra={'violations': violations}
|
||||
))
|
||||
|
||||
return cards
|
||||
|
||||
def process_dify_intent(
|
||||
self,
|
||||
dify_result: Dict,
|
||||
employee_id: str,
|
||||
profile: Optional[Dict] = None
|
||||
) -> List[RecommendCard]:
|
||||
"""
|
||||
处理 Dify 意图输出,生成资产推荐
|
||||
|
||||
Args:
|
||||
dify_result: Dify 返回的 JSON(包含 intent, need_asset, asset_keywords)
|
||||
employee_id: 员工 ID
|
||||
profile: 员工画像(可选)
|
||||
|
||||
Returns:
|
||||
推荐卡片列表
|
||||
"""
|
||||
recommends = []
|
||||
|
||||
# L1: Dify 意图触发的资产
|
||||
if dify_result.get('need_asset') and dify_result.get('asset_keywords'):
|
||||
for keyword in dify_result['asset_keywords']:
|
||||
asset = self.get_by_keyword(keyword)
|
||||
if asset:
|
||||
card = self._build_card_from_asset(
|
||||
keyword,
|
||||
asset,
|
||||
source='dify_intent',
|
||||
layer='L1',
|
||||
layer_label='相关推荐'
|
||||
)
|
||||
card.confidence = dify_result.get('confidence', 0.85)
|
||||
recommends.append(card)
|
||||
|
||||
# L2: 画像触发(如果提供了 profile)
|
||||
if profile:
|
||||
profile_recs = self.match_profile_triggers(profile)
|
||||
recommends.extend(profile_recs)
|
||||
|
||||
# L3: 角色通用(如果提供了 profile)
|
||||
if profile:
|
||||
role = profile.get('position') or profile.get('role', '')
|
||||
if role:
|
||||
role_recs = self.get_by_role(role)
|
||||
for rec in role_recs:
|
||||
rec.layer = 'L3'
|
||||
rec.layer_label = '常用资源'
|
||||
rec.relevance = 'low'
|
||||
recommends.extend(role_recs)
|
||||
|
||||
return recommends
|
||||
|
||||
|
||||
# 全局单例
|
||||
_asset_recommend_service: Optional[AssetRecommendService] = None
|
||||
|
||||
|
||||
def get_asset_recommend_service() -> AssetRecommendService:
|
||||
"""获取资产推荐服务单例"""
|
||||
global _asset_recommend_service
|
||||
if _asset_recommend_service is None:
|
||||
_asset_recommend_service = AssetRecommendService()
|
||||
return _asset_recommend_service
|
||||
@@ -9,13 +9,14 @@
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import and_, case, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -212,3 +213,108 @@ class SessionQueryService:
|
||||
raise ERR_CONVERSATION_NOT_FOUND
|
||||
|
||||
return conversation
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 获取员工历史消息(跨会话聚合)
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_employee_history_messages(
|
||||
self,
|
||||
employee_id: str,
|
||||
limit: int = 50,
|
||||
before: Optional[str] = None,
|
||||
current_conversation_id: Optional[str] = None,
|
||||
) -> Tuple[List[Message], bool, Dict[str, str]]:
|
||||
"""获取员工的历史消息(跨会话聚合)。
|
||||
|
||||
将同一员工的所有会话消息合并为一条时间线,按时间排序。
|
||||
用于"历史会话"功能,让坐席查看员工过去的所有咨询记录。
|
||||
|
||||
查询逻辑:
|
||||
1. 查询 conversations 表中 employee_id = ? 的所有会话ID
|
||||
2. 查询 messages 表中 conversation_id IN (会话ID列表) 的消息
|
||||
3. 如有 before 参数,获取该消息的 created_at,只查更早的消息
|
||||
4. 按 created_at DESC 排序,取 limit + 1 条(多取1条判断 has_more)
|
||||
5. 对涉及的每个会话,查询其 sender_type='employee' 的最早一条消息,
|
||||
取前20字作为摘要
|
||||
6. 返回消息列表 + has_more + conversation_summaries
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID
|
||||
limit: 每页消息数量(默认50)
|
||||
before: 游标消息ID,只查该消息之前的消息(向上翻页)
|
||||
current_conversation_id: 当前会话ID(仅用于标记,不影响查询逻辑)
|
||||
|
||||
Returns:
|
||||
tuple: (消息列表, 是否还有更多, {conversation_id: "前20字摘要"})
|
||||
"""
|
||||
# 1. 查询该员工的所有会话ID
|
||||
conv_stmt = select(Conversation.id).where(
|
||||
Conversation.employee_id == employee_id
|
||||
)
|
||||
conv_result = await self.db.execute(conv_stmt)
|
||||
conversation_ids = [row[0] for row in conv_result.all()]
|
||||
|
||||
if not conversation_ids:
|
||||
# 该员工没有任何会话
|
||||
return [], False, {}
|
||||
|
||||
# 2. 构建消息查询(跨会话聚合,按时间倒序)
|
||||
stmt = select(Message).where(
|
||||
Message.conversation_id.in_(conversation_ids)
|
||||
).order_by(Message.created_at.desc())
|
||||
|
||||
# 3. 如有 before 参数,获取该消息的 created_at,只查更早的消息
|
||||
if before:
|
||||
try:
|
||||
before_stmt = select(Message.created_at).where(
|
||||
Message.id == str(before)
|
||||
)
|
||||
before_result = await self.db.execute(before_stmt)
|
||||
before_time = before_result.scalar_one_or_none()
|
||||
if before_time:
|
||||
stmt = stmt.where(Message.created_at < before_time)
|
||||
except Exception:
|
||||
pass # before 参数格式错误,忽略
|
||||
|
||||
# 4. 取 limit + 1 条(多取1条判断 has_more)
|
||||
stmt = stmt.limit(limit + 1)
|
||||
result = await self.db.execute(stmt)
|
||||
messages = list(result.scalars().all())
|
||||
|
||||
# 判断是否还有更多消息
|
||||
has_more = len(messages) > limit
|
||||
if has_more:
|
||||
messages = messages[:limit]
|
||||
|
||||
# 5. 对涉及的每个会话,查询其 sender_type='employee' 的最早一条消息摘要
|
||||
involved_conv_ids = list(set(m.conversation_id for m in messages))
|
||||
conversation_summaries: Dict[str, str] = {}
|
||||
|
||||
for conv_id in involved_conv_ids:
|
||||
# 查询该会话中员工发送的最早一条消息
|
||||
summary_stmt = (
|
||||
select(Message.content)
|
||||
.where(
|
||||
and_(
|
||||
Message.conversation_id == conv_id,
|
||||
Message.sender_type == "employee",
|
||||
)
|
||||
)
|
||||
.order_by(Message.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
summary_result = await self.db.execute(summary_stmt)
|
||||
first_employee_msg = summary_result.scalar_one_or_none()
|
||||
if first_employee_msg:
|
||||
# 取前20字作为摘要
|
||||
conversation_summaries[conv_id] = first_employee_msg[:20]
|
||||
else:
|
||||
conversation_summaries[conv_id] = "未知会话"
|
||||
|
||||
logger.debug(
|
||||
f"查询员工历史消息: employee_id={employee_id}, "
|
||||
f"conv_count={len(conversation_ids)}, "
|
||||
f"msg_count={len(messages)}, has_more={has_more}"
|
||||
)
|
||||
|
||||
return messages, has_more, conversation_summaries
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# =============================================================================
|
||||
# 设备清单导入服务
|
||||
# =============================================================================
|
||||
# 说明:从联软/火绒导出的 Excel 文件中导入设备清单
|
||||
# =============================================================================
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.device_inventory import DeviceInventory
|
||||
|
||||
|
||||
class DeviceImportService:
|
||||
"""设备清单导入服务。
|
||||
|
||||
从联软/火绒导出的 Excel 文件中解析并导入设备清单。
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def import_huorong(self, records: List[Dict]) -> int:
|
||||
"""导入火绒设备清单。
|
||||
|
||||
火绒字段映射:
|
||||
- 终端名称 -> computer_name
|
||||
- 本地IP -> ip_address
|
||||
- MAC地址 -> mac_address
|
||||
- 员工账户 -> employee_account
|
||||
- 责任人 -> employee_name
|
||||
- 部门名称 -> department
|
||||
- 电脑固定资产编号 -> asset_tag
|
||||
"""
|
||||
count = 0
|
||||
for record in records:
|
||||
# 跳过无效记录
|
||||
if not record.get("终端名称"):
|
||||
continue
|
||||
|
||||
computer_name = str(record.get("终端名称", "")).strip()
|
||||
ip_address = str(record.get("本地IP", "")).strip()
|
||||
mac_address = str(record.get("MAC地址", "")).strip()
|
||||
employee_account = str(record.get("员工账户", "")).strip()
|
||||
employee_name = str(record.get("责任人", "")).strip()
|
||||
asset_tag = str(record.get("电脑固定资产编号", "")).strip()
|
||||
|
||||
# 检查是否已存在(根据 computer_name + source)
|
||||
existing = await self.db.execute(
|
||||
select(DeviceInventory).where(
|
||||
DeviceInventory.computer_name == computer_name,
|
||||
DeviceInventory.source == "huorong",
|
||||
)
|
||||
)
|
||||
existing = existing.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# 更新
|
||||
existing.ip_address = ip_address
|
||||
existing.mac_address = mac_address
|
||||
existing.employee_account = employee_account
|
||||
existing.employee_name = employee_name
|
||||
existing.asset_tag = asset_tag
|
||||
existing.updated_at = datetime.now()
|
||||
else:
|
||||
# 新增
|
||||
device = DeviceInventory(
|
||||
id=str(uuid.uuid4()),
|
||||
computer_name=computer_name,
|
||||
ip_address=ip_address,
|
||||
mac_address=mac_address,
|
||||
employee_account=employee_account,
|
||||
employee_name=employee_name,
|
||||
source="huorong",
|
||||
asset_tag=asset_tag,
|
||||
)
|
||||
self.db.add(device)
|
||||
|
||||
count += 1
|
||||
|
||||
await self.db.commit()
|
||||
return count
|
||||
|
||||
async def import_lianruan(self, records: List[Dict]) -> int:
|
||||
"""导入联软设备清单。
|
||||
|
||||
联软字段映射:
|
||||
- 设备名称 -> computer_name
|
||||
- 设备IP -> ip_address
|
||||
- MAC地址 -> mac_address
|
||||
- 用户姓名 -> employee_name
|
||||
- 部门名称 -> department
|
||||
- 设备IP -> ip_address
|
||||
"""
|
||||
count = 0
|
||||
for record in records:
|
||||
# 跳过无效记录
|
||||
if not record.get("设备名称"):
|
||||
continue
|
||||
|
||||
computer_name = str(record.get("设备名称", "")).strip()
|
||||
ip_address = str(record.get("设备IP", "")).strip()
|
||||
mac_address = str(record.get("MAC地址", "")).strip()
|
||||
employee_name = str(record.get("用户姓名", "")).strip()
|
||||
department = str(record.get("部门名称", "")).strip()
|
||||
|
||||
# 检查是否已存在
|
||||
existing = await self.db.execute(
|
||||
select(DeviceInventory).where(
|
||||
DeviceInventory.computer_name == computer_name,
|
||||
DeviceInventory.source == "lianruan",
|
||||
)
|
||||
)
|
||||
existing = existing.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# 更新
|
||||
existing.ip_address = ip_address
|
||||
existing.mac_address = mac_address
|
||||
existing.employee_name = employee_name
|
||||
existing.department = department
|
||||
existing.updated_at = datetime.now()
|
||||
else:
|
||||
# 新增
|
||||
device = DeviceInventory(
|
||||
id=str(uuid.uuid4()),
|
||||
computer_name=computer_name,
|
||||
ip_address=ip_address,
|
||||
mac_address=mac_address,
|
||||
employee_name=employee_name,
|
||||
department=department,
|
||||
source="lianruan",
|
||||
)
|
||||
self.db.add(device)
|
||||
|
||||
count += 1
|
||||
|
||||
await self.db.commit()
|
||||
return count
|
||||
|
||||
async def find_by_employee(self, employee_account: str) -> Optional[DeviceInventory]:
|
||||
"""根据员工账号查找设备。
|
||||
|
||||
优先从火绒数据查找(因为火绒有员工账户字段)。
|
||||
"""
|
||||
# 先查火绒
|
||||
result = await self.db.execute(
|
||||
select(DeviceInventory).where(
|
||||
DeviceInventory.employee_account == employee_account,
|
||||
DeviceInventory.source == "huorong",
|
||||
)
|
||||
)
|
||||
device = result.scalar_one_or_none()
|
||||
if device:
|
||||
return device
|
||||
|
||||
# 再查联软(通过员工姓名匹配)
|
||||
# 先获取员工姓名
|
||||
from app.services.employee_directory import EmployeeDirectoryService
|
||||
|
||||
emp_service = EmployeeDirectoryService(self.db)
|
||||
emp_info = await emp_service.get_employee_by_id(employee_account)
|
||||
if emp_info:
|
||||
emp_name = emp_info.get("name", "")
|
||||
if emp_name:
|
||||
result = await self.db.execute(
|
||||
select(DeviceInventory).where(
|
||||
DeviceInventory.employee_name == emp_name,
|
||||
DeviceInventory.source == "lianruan",
|
||||
)
|
||||
)
|
||||
device = result.scalar_one_or_none()
|
||||
if device:
|
||||
return device
|
||||
|
||||
return None
|
||||
|
||||
async def find_by_ip(self, ip_address: str) -> Optional[DeviceInventory]:
|
||||
"""根据 IP 地址查找设备。"""
|
||||
result = await self.db.execute(
|
||||
select(DeviceInventory).where(
|
||||
DeviceInventory.ip_address == ip_address,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -15,6 +15,7 @@
|
||||
# 姓名搜索能力(full_directory=True),缺权限时仅覆盖已登录员工。
|
||||
# =============================================================================
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -28,23 +29,31 @@ from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 组织目录 Redis 缓存 key 与 TTL(10 分钟,避免频繁调用企微通讯录 API)
|
||||
# 组织目录 Redis 缓存 key 与 TTL(30 分钟,减少企微通讯录 API 调用频率)
|
||||
ORG_DIRECTORY_CACHE_KEY = "wecom:org_directory"
|
||||
ORG_DIRECTORY_CACHE_TTL = 600
|
||||
ORG_DIRECTORY_CACHE_TTL = 1800
|
||||
|
||||
# 部门列表 Redis 缓存 key 与 TTL(含 parentid 层级信息,供 org tree 端点复用)
|
||||
DEPT_LIST_CACHE_KEY = "wecom:dept_list"
|
||||
DEPT_LIST_CACHE_TTL = 1800
|
||||
|
||||
# 组织架构树 Redis 缓存 key 前缀与 TTL(按端点区分,树构建是纯计算,数据源变了才需重建)
|
||||
ORG_TREE_CACHE_KEY_PREFIX = "wecom:org_tree"
|
||||
ORG_TREE_CACHE_TTL = 1800
|
||||
|
||||
|
||||
async def get_org_directory(
|
||||
db: AsyncSession,
|
||||
redis: Optional[aioredis.Redis],
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""获取「组织目录」(员工账号 + 姓名 列表),用于姓名 -> 账号匹配。
|
||||
"""获取「组织目录」(员工账号 + 姓名 + 部门ID列表),用于姓名 -> 账号匹配和组织架构树构建。
|
||||
|
||||
优先返回缓存;缓存未命中时尝试从企微通讯录拉全组织(需通讯录读取权限)。
|
||||
优先返回缓存;缓存未命中时并行从企微通讯录拉取全组织成员和部门列表(需通讯录读取权限)。
|
||||
若企微权限不足或调用失败,降级到本地 employees 表。
|
||||
|
||||
Returns:
|
||||
(directory, full_directory)
|
||||
- directory: [{"employee_id": str, "name": str, "department": str}, ...]
|
||||
- directory: [{"employee_id": str, "name": str, "department": str, "dept_ids": [int, ...]}, ...]
|
||||
- full_directory: True=来自企微全组织(覆盖全公司);False=仅本地已登录员工
|
||||
"""
|
||||
# 1. 尝试命中缓存(缓存一定来自企微全组织,full=True)
|
||||
@@ -57,25 +66,51 @@ async def get_org_directory(
|
||||
except Exception as e:
|
||||
logger.warning(f"读取组织目录缓存失败(降级): {e}")
|
||||
|
||||
# 2. 尝试从企微通讯录拉全组织
|
||||
# 2. 尝试从企微通讯录拉全组织(并行调用两个 API,减少串行等待时间)
|
||||
wecom = WecomService(redis_client=redis)
|
||||
try:
|
||||
members = await wecom.get_department_members(1, 1)
|
||||
# 并行拉取部门成员和部门列表(return_exceptions=True 防止单个失败影响整体)
|
||||
results = await asyncio.gather(
|
||||
wecom.get_department_members(1, 1),
|
||||
wecom.get_department_list(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
members_result = results[0]
|
||||
dept_result = results[1]
|
||||
|
||||
# 获取部门列表,构建 {部门ID: 部门名称} 映射,用于将成员的 department ID 列表
|
||||
# 部门成员获取失败(权限不足等)→ 抛出异常触发降级到本地 employees 表
|
||||
if isinstance(members_result, Exception):
|
||||
raise members_result
|
||||
|
||||
members = members_result
|
||||
# 部门列表获取失败时不阻塞主流程,降级使用部门ID字符串
|
||||
departments: List[Dict[str, Any]] = (
|
||||
dept_result if not isinstance(dept_result, Exception) else []
|
||||
)
|
||||
if isinstance(dept_result, Exception):
|
||||
logger.warning(f"获取部门列表失败,降级使用部门ID字符串: {dept_result}")
|
||||
|
||||
# 构建 {部门ID: 部门名称} 映射,用于将成员的 department ID 列表
|
||||
# 转换为可读的部门名称(企微 user/list 返回的 department 字段是 ID 列表如 [1,2])
|
||||
dept_map: Dict[int, str] = {}
|
||||
try:
|
||||
departments = await wecom.get_department_list()
|
||||
if departments:
|
||||
dept_map = {
|
||||
dept.get("id"): dept.get("name", "")
|
||||
for dept in departments
|
||||
if dept.get("id") is not None
|
||||
}
|
||||
logger.info(f"部门列表获取成功,共 {len(dept_map)} 个部门")
|
||||
except Exception as e:
|
||||
# 获取部门列表失败(权限不足等)时降级:使用原来的 ID 字符串,不阻塞主流程
|
||||
logger.warning(f"获取部门列表失败,降级使用部门ID字符串: {e}")
|
||||
|
||||
# 缓存部门列表(含 parentid 层级信息),供 org tree 端点复用
|
||||
if redis:
|
||||
try:
|
||||
await redis.setex(
|
||||
DEPT_LIST_CACHE_KEY,
|
||||
DEPT_LIST_CACHE_TTL,
|
||||
json.dumps(departments, ensure_ascii=False),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"写入部门列表缓存失败: {e}")
|
||||
|
||||
directory = [
|
||||
{
|
||||
@@ -85,6 +120,8 @@ async def get_org_directory(
|
||||
"department": ",".join(
|
||||
dept_map.get(d, str(d)) for d in (m.get("department") or [])
|
||||
),
|
||||
# 保留原始部门 ID 列表,供 org tree 端点构建层级树使用
|
||||
"dept_ids": list(m.get("department") or []),
|
||||
}
|
||||
for m in members
|
||||
if m.get("userid")
|
||||
@@ -122,12 +159,13 @@ async def get_org_directory(
|
||||
# 尝试解析 JSON 数组格式(生产环境企微返回的是部门ID列表如 "[1,2]")
|
||||
# 如果不是 JSON 格式(DEV_MODE 下直接存部门名),则原样使用
|
||||
dept_name = ""
|
||||
dept_ids: List[int] = []
|
||||
if dept_raw:
|
||||
try:
|
||||
import json as _json
|
||||
parsed = _json.loads(dept_raw)
|
||||
parsed = json.loads(dept_raw)
|
||||
if isinstance(parsed, list) and parsed:
|
||||
# 部门ID列表:取第一个ID(降级时无法解析ID为名称,留空)
|
||||
# 部门ID列表:降级时无法解析ID为名称,留空;但保留ID供树构建
|
||||
dept_ids = [int(d) for d in parsed if d is not None]
|
||||
dept_name = ""
|
||||
else:
|
||||
dept_name = str(parsed)
|
||||
@@ -138,6 +176,7 @@ async def get_org_directory(
|
||||
"employee_id": r[0],
|
||||
"name": r[1] or "",
|
||||
"department": dept_name,
|
||||
"dept_ids": dept_ids,
|
||||
})
|
||||
logger.info(f"组织目录降级到本地 employees 表,共 {len(directory)} 人")
|
||||
return directory, False
|
||||
@@ -235,3 +274,363 @@ async def resolve_target(
|
||||
"reason": f"未找到匹配「{target}」的员工",
|
||||
"suggestion": "当前仅能按姓名搜索已登录过本系统的员工;请直接输入员工账号,或为企微应用开通「通讯录读取」权限以搜索全公司",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 组织架构树构建(利用企微 department/list 的 parentid 构建真正的层级树)
|
||||
# =============================================================================
|
||||
# 说明:以下函数用于将扁平的员工目录 + 部门列表转换为层级组织架构树。
|
||||
# - build_org_tree() 纯函数:根据 directory + dept_list 构建层级树
|
||||
# - get_cached_dept_list() 从 Redis 缓存读取部门列表(含 parentid)
|
||||
# - filter_user_from_tree() 从树中递归过滤掉指定用户
|
||||
# - count_tree_employees() 统计树中的员工总数
|
||||
# - get_org_tree_cached() 获取组织架构树(含独立缓存 + 用户过滤)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def get_cached_dept_list(
|
||||
redis: Optional[aioredis.Redis],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""从缓存获取部门列表(含 parentid 层级信息)。
|
||||
|
||||
优先从 Redis 缓存读取;缓存未命中时调用企微 API 获取并缓存。
|
||||
供 org tree 端点复用,避免重复解析。
|
||||
|
||||
Args:
|
||||
redis: Redis 客户端(可选)
|
||||
|
||||
Returns:
|
||||
部门列表,每项含 id(部门ID)、name(部门名称)、parentid(父部门ID)
|
||||
"""
|
||||
if redis:
|
||||
try:
|
||||
raw = await redis.get(DEPT_LIST_CACHE_KEY)
|
||||
if raw:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning(f"读取部门列表缓存失败: {e}")
|
||||
|
||||
# 缓存未命中,调用企微 API 获取
|
||||
wecom = WecomService(redis_client=redis)
|
||||
try:
|
||||
departments = await wecom.get_department_list()
|
||||
if redis and departments:
|
||||
try:
|
||||
await redis.setex(
|
||||
DEPT_LIST_CACHE_KEY,
|
||||
DEPT_LIST_CACHE_TTL,
|
||||
json.dumps(departments, ensure_ascii=False),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"写入部门列表缓存失败: {e}")
|
||||
return departments
|
||||
except Exception as e:
|
||||
logger.warning(f"获取部门列表失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def build_org_tree(
|
||||
directory: List[Dict[str, Any]],
|
||||
dept_list: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""根据部门列表的 parentid 层级信息和员工目录构建真正的层级组织架构树。
|
||||
|
||||
不再按部门名扁平分组,而是利用企微 department/list 返回的 parentid 字段
|
||||
构建真正的层级树。部门ID加 ``dept_`` 前缀作为唯一 key,避免同名部门合并。
|
||||
员工可以出现在其所属的所有部门下(不只取第一个)。
|
||||
|
||||
Args:
|
||||
directory: 员工目录列表(每项含 employee_id, name, department, dept_ids)
|
||||
dept_list: 部门列表(每项含 id, name, parentid)
|
||||
|
||||
Returns:
|
||||
层级树节点列表,每个节点为:
|
||||
- 部门节点: {id, label, dept_id, parentid, children: [...]}
|
||||
- 员工节点: {id, label, isLeaf: true, department: str}
|
||||
"""
|
||||
# 部门列表为空时(权限不足或降级模式),退化为按部门名扁平分组
|
||||
if not dept_list:
|
||||
return _build_flat_tree_by_name(directory)
|
||||
|
||||
# 1. 构建部门映射:dept_id → 部门信息
|
||||
dept_map: Dict[int, Dict[str, Any]] = {}
|
||||
for dept in dept_list:
|
||||
dept_id = dept.get("id")
|
||||
if dept_id is not None:
|
||||
dept_map[dept_id] = dept
|
||||
|
||||
# 2. 构建父部门→子部门ID列表映射
|
||||
children_map: Dict[int, List[int]] = {}
|
||||
for dept_id, dept in dept_map.items():
|
||||
parent_id = dept.get("parentid", 0)
|
||||
if parent_id not in children_map:
|
||||
children_map[parent_id] = []
|
||||
children_map[parent_id].append(dept_id)
|
||||
|
||||
# 3. 构建部门→员工映射(一个员工可属于多个部门)
|
||||
dept_employees: Dict[int, List[Dict[str, Any]]] = {}
|
||||
unassigned_employees: List[Dict[str, Any]] = []
|
||||
for emp in directory:
|
||||
emp_id = emp.get("employee_id", "")
|
||||
emp_name = emp.get("name", "")
|
||||
dept_ids = emp.get("dept_ids") or []
|
||||
if not dept_ids:
|
||||
# 没有部门ID的员工归到"未分配部门"
|
||||
unassigned_employees.append({
|
||||
"id": emp_id,
|
||||
"label": emp_name,
|
||||
"isLeaf": True,
|
||||
"department": emp.get("department", ""),
|
||||
})
|
||||
continue
|
||||
for did in dept_ids:
|
||||
if did not in dept_employees:
|
||||
dept_employees[did] = []
|
||||
# 同一员工可能属于多个部门,在每个部门下都出现
|
||||
dept_employees[did].append({
|
||||
"id": emp_id,
|
||||
"label": emp_name,
|
||||
"isLeaf": True,
|
||||
"department": emp.get("department", ""),
|
||||
})
|
||||
|
||||
# 4. 递归构建部门子树
|
||||
def build_dept_node(dept_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""递归构建单个部门的树节点(含子部门和员工)。"""
|
||||
dept = dept_map.get(dept_id)
|
||||
if dept is None:
|
||||
return None
|
||||
dept_name = dept.get("name", "") or f"部门{dept_id}"
|
||||
node: Dict[str, Any] = {
|
||||
"id": f"dept_{dept_id}",
|
||||
"label": dept_name,
|
||||
"dept_id": dept_id,
|
||||
"parentid": dept.get("parentid", 0),
|
||||
"children": [],
|
||||
}
|
||||
# 添加子部门(按名称排序)
|
||||
child_ids = children_map.get(dept_id, [])
|
||||
for child_id in sorted(
|
||||
child_ids,
|
||||
key=lambda cid: (dept_map.get(cid, {}).get("name", "") or ""),
|
||||
):
|
||||
child_node = build_dept_node(child_id)
|
||||
if child_node:
|
||||
node["children"].append(child_node)
|
||||
# 添加该部门下的员工(按姓名排序)
|
||||
emps = dept_employees.get(dept_id, [])
|
||||
emps.sort(key=lambda e: e.get("label", ""))
|
||||
node["children"].extend(emps)
|
||||
return node
|
||||
|
||||
# 5. 确定根部门作为顶层节点
|
||||
# 优先取 parentid=0 的部门;若无,则取 parentid=1 的部门
|
||||
# 孤儿部门(parentid 不在 dept_map 中)也作为顶层节点
|
||||
root_dept_ids: List[int] = []
|
||||
root_id_set: set = set()
|
||||
has_parentid_0 = any(d.get("parentid") == 0 for d in dept_map.values())
|
||||
for did, dept in dept_map.items():
|
||||
parent_id = dept.get("parentid", 0)
|
||||
if has_parentid_0 and parent_id == 0:
|
||||
root_id_set.add(did)
|
||||
elif not has_parentid_0 and parent_id == 1:
|
||||
root_id_set.add(did)
|
||||
elif parent_id not in dept_map:
|
||||
# 孤儿部门(父部门不存在于部门列表中)也作为根
|
||||
root_id_set.add(did)
|
||||
|
||||
# 按部门名称排序
|
||||
root_dept_ids = sorted(
|
||||
root_id_set,
|
||||
key=lambda did: (dept_map.get(did, {}).get("name", "") or ""),
|
||||
)
|
||||
|
||||
tree: List[Dict[str, Any]] = []
|
||||
for dept_id in root_dept_ids:
|
||||
node = build_dept_node(dept_id)
|
||||
if node:
|
||||
tree.append(node)
|
||||
|
||||
# 6. 添加"未分配部门"(仅在没有部门信息的极少数员工时出现)
|
||||
if unassigned_employees:
|
||||
unassigned_employees.sort(key=lambda e: e.get("label", ""))
|
||||
tree.append({
|
||||
"id": "dept_unassigned",
|
||||
"label": "未分配部门",
|
||||
"dept_id": None,
|
||||
"parentid": 0,
|
||||
"children": unassigned_employees,
|
||||
})
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
def _build_flat_tree_by_name(
|
||||
directory: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""按部门名扁平分组构建树(降级模式:部门列表不可用时使用)。
|
||||
|
||||
保留原有逻辑:按 department 名称分组,多部门取第一个,空部门归"未分配部门"。
|
||||
部门按名称排序,员工按姓名排序。
|
||||
|
||||
Args:
|
||||
directory: 员工目录列表
|
||||
|
||||
Returns:
|
||||
扁平树节点列表(一级部门 + 员工叶子节点)
|
||||
"""
|
||||
dept_groups: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for emp in directory:
|
||||
dept = (emp.get("department") or "").strip()
|
||||
if not dept:
|
||||
dept = "未分配部门"
|
||||
else:
|
||||
dept = dept.split(",")[0].strip()
|
||||
if not dept:
|
||||
dept = "未分配部门"
|
||||
if dept not in dept_groups:
|
||||
dept_groups[dept] = []
|
||||
dept_groups[dept].append(emp)
|
||||
|
||||
tree: List[Dict[str, Any]] = []
|
||||
for dept_name in sorted(dept_groups.keys()):
|
||||
employees = dept_groups[dept_name]
|
||||
if not employees:
|
||||
continue
|
||||
employees.sort(key=lambda e: e.get("name", ""))
|
||||
tree.append({
|
||||
"id": f"dept_name_{dept_name}",
|
||||
"label": dept_name,
|
||||
"dept_id": None,
|
||||
"parentid": 0,
|
||||
"children": [
|
||||
{
|
||||
"id": emp.get("employee_id", ""),
|
||||
"label": emp.get("name", ""),
|
||||
"isLeaf": True,
|
||||
"department": dept_name,
|
||||
}
|
||||
for emp in employees
|
||||
],
|
||||
})
|
||||
return tree
|
||||
|
||||
|
||||
def filter_user_from_tree(
|
||||
tree: List[Dict[str, Any]],
|
||||
user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""从组织架构树中递归过滤掉指定用户,并移除过滤后变空的部门节点。
|
||||
|
||||
用于 org tree 端点排除当前登录用户。由于树缓存包含所有员工,
|
||||
读取后需过滤掉当前用户再返回。
|
||||
|
||||
Args:
|
||||
tree: 完整的组织架构树(包含所有员工)
|
||||
user_id: 要排除的用户 UserID
|
||||
|
||||
Returns:
|
||||
过滤后的树(不含目标用户,也不含因此变空的部门节点)
|
||||
"""
|
||||
result: List[Dict[str, Any]] = []
|
||||
for node in tree:
|
||||
# 员工叶子节点:跳过目标用户
|
||||
if node.get("isLeaf"):
|
||||
if node.get("id") == user_id:
|
||||
continue
|
||||
result.append(node)
|
||||
continue
|
||||
|
||||
# 部门节点:递归过滤子节点
|
||||
children = node.get("children")
|
||||
if children is not None:
|
||||
new_children = filter_user_from_tree(children, user_id)
|
||||
if not new_children:
|
||||
# 过滤后部门为空,跳过该部门节点
|
||||
continue
|
||||
new_node = dict(node)
|
||||
new_node["children"] = new_children
|
||||
result.append(new_node)
|
||||
else:
|
||||
result.append(node)
|
||||
return result
|
||||
|
||||
|
||||
def count_tree_employees(tree: List[Dict[str, Any]]) -> int:
|
||||
"""统计组织架构树中的员工总数(递归计算叶子节点数)。
|
||||
|
||||
Args:
|
||||
tree: 组织架构树
|
||||
|
||||
Returns:
|
||||
员工总数
|
||||
"""
|
||||
count = 0
|
||||
for node in tree:
|
||||
if node.get("isLeaf"):
|
||||
count += 1
|
||||
elif "children" in node:
|
||||
count += count_tree_employees(node["children"])
|
||||
return count
|
||||
|
||||
|
||||
async def get_org_tree_cached(
|
||||
db: AsyncSession,
|
||||
redis: Optional[aioredis.Redis],
|
||||
endpoint: str,
|
||||
exclude_user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取组织架构树(含独立缓存 + 排除当前用户)。
|
||||
|
||||
树构建是纯计算,数据源变了才需重建,因此独立缓存(TTL 30 分钟)。
|
||||
缓存中包含所有员工,读取后过滤掉当前用户再返回。
|
||||
|
||||
缓存策略:
|
||||
1. 优先读取 org tree 独立缓存
|
||||
2. 缓存未命中时,从 directory 缓存 + dept_list 缓存构建树
|
||||
3. 构建完成后写入 org tree 缓存
|
||||
|
||||
Args:
|
||||
db: 数据库会话(用于 get_org_directory 降级)
|
||||
redis: Redis 客户端
|
||||
endpoint: 端点标识("agent" 或 "h5"),用于区分缓存 key
|
||||
exclude_user_id: 要排除的用户 UserID(当前登录用户)
|
||||
|
||||
Returns:
|
||||
组织架构树节点列表(已排除当前用户)
|
||||
"""
|
||||
cache_key = f"{ORG_TREE_CACHE_KEY_PREFIX}:{endpoint}"
|
||||
|
||||
# 1. 尝试命中树缓存
|
||||
tree: Optional[List[Dict[str, Any]]] = None
|
||||
if redis:
|
||||
try:
|
||||
raw = await redis.get(cache_key)
|
||||
if raw:
|
||||
tree = json.loads(raw.decode("utf-8"))
|
||||
logger.debug(f"命中组织架构树缓存: {endpoint}")
|
||||
except Exception as e:
|
||||
logger.warning(f"读取组织架构树缓存失败: {e}")
|
||||
|
||||
# 2. 缓存未命中,构建树
|
||||
if tree is None:
|
||||
# 获取员工目录(含 Redis 缓存 + 本地降级)
|
||||
directory, _ = await get_org_directory(db, redis)
|
||||
# 获取部门列表(含 parentid 层级信息,从缓存或 API 获取)
|
||||
dept_list = await get_cached_dept_list(redis)
|
||||
# 构建层级树
|
||||
tree = build_org_tree(directory, dept_list)
|
||||
|
||||
# 写入树缓存
|
||||
if redis and tree:
|
||||
try:
|
||||
await redis.setex(
|
||||
cache_key,
|
||||
ORG_TREE_CACHE_TTL,
|
||||
json.dumps(tree, ensure_ascii=False),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"写入组织架构树缓存失败: {e}")
|
||||
|
||||
# 3. 过滤掉当前用户(缓存包含所有员工,需按请求者排除)
|
||||
return filter_user_from_tree(tree, exclude_user_id)
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
员工画像服务 (Employee Profile Service)
|
||||
|
||||
统一对接多个数据源,构建员工画像:
|
||||
- 企微通讯录:员工基本信息
|
||||
- 联软终端安全:补丁数、违规项
|
||||
- 火绒终端安全:终端版本、病毒库日期
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, asdict
|
||||
import asyncio
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmployeeProfile:
|
||||
"""员工画像数据模型"""
|
||||
employee_id: str
|
||||
name: str
|
||||
department: str
|
||||
position: str
|
||||
mobile: str
|
||||
|
||||
# 联软数据
|
||||
unionsoft_patches_missing: int = 0
|
||||
unionsoft_last_scan: Optional[datetime] = None
|
||||
unionsoft_violations: List[str] = None
|
||||
|
||||
# 火绒数据
|
||||
huorong_version: str = ""
|
||||
huorong_virusdb_date: Optional[datetime] = None
|
||||
huorong_offline_days: int = 0
|
||||
|
||||
# 元数据
|
||||
last_updated: Optional[datetime] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.unionsoft_violations is None:
|
||||
self.unionsoft_violations = []
|
||||
|
||||
|
||||
class EmployeeProfileService:
|
||||
"""
|
||||
员工画像服务
|
||||
|
||||
功能:
|
||||
1. 从企微/联软/火绒聚合员工画像
|
||||
2. Redis 缓存,避免频繁调用第三方 API
|
||||
3. 并行获取,提升响应速度
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
# Redis 连接
|
||||
redis_url = os.environ.get(
|
||||
'REDIS_URL',
|
||||
'redis://localhost:6379/0'
|
||||
)
|
||||
self.redis = redis.from_url(redis_url, decode_responses=True)
|
||||
|
||||
# 缓存 TTL(秒)
|
||||
self.cache_ttl = int(os.environ.get('EMPLOYEE_PROFILE_TTL', '3600')) # 默认 1 小时
|
||||
|
||||
# 客户端初始化(延迟加载)
|
||||
self._lianruan_client = None
|
||||
self._huorong_client = None
|
||||
self._wecom_service = None
|
||||
|
||||
logger.info("[EmployeeProfile] 服务初始化完成")
|
||||
|
||||
@property
|
||||
def lianruan_client(self):
|
||||
"""延迟加载联软客户端"""
|
||||
if self._lianruan_client is None:
|
||||
from app.integrations.lianruan.client import LianruanClient
|
||||
from app.integrations.lianruan.config import LianruanConfig
|
||||
|
||||
config = LianruanConfig()
|
||||
self._lianruan_client = LianruanClient(
|
||||
base_url=config.base_url,
|
||||
api_account=config.api_account,
|
||||
api_password=config.api_password,
|
||||
validate_key=config.validate_key
|
||||
)
|
||||
return self._lianruan_client
|
||||
|
||||
@property
|
||||
def huorong_client(self):
|
||||
"""延迟加载火绒客户端"""
|
||||
if self._huorong_client is None:
|
||||
from app.integrations.huorong.client import HuorongClient
|
||||
from app.integrations.huorong.config import HuorongConfig
|
||||
|
||||
config = HuorongConfig()
|
||||
self._huorong_client = HuorongClient(
|
||||
access_key_id=config.access_key_id,
|
||||
access_key_secret=config.access_key_secret,
|
||||
base_url=config.base_url
|
||||
)
|
||||
return self._huorong_client
|
||||
|
||||
@property
|
||||
def wecom_service(self):
|
||||
"""延迟加载企微服务"""
|
||||
if self._wecom_service is None:
|
||||
from app.services.wecom_service import WecomService
|
||||
self._wecom_service = WecomService()
|
||||
return self._wecom_service
|
||||
|
||||
async def get_profile(self, employee_id: str) -> EmployeeProfile:
|
||||
"""
|
||||
获取员工画像(带缓存)
|
||||
|
||||
Args:
|
||||
employee_id: 员工 ID(企微 UserID)
|
||||
|
||||
Returns:
|
||||
员工画像对象
|
||||
"""
|
||||
cache_key = f"employee_profile:{employee_id}"
|
||||
|
||||
try:
|
||||
# 1. 尝试从缓存获取
|
||||
cached = await self.redis.get(cache_key)
|
||||
if cached:
|
||||
data = json.loads(cached)
|
||||
logger.debug(f"[EmployeeProfile] 缓存命中: {employee_id}")
|
||||
return EmployeeProfile(**data)
|
||||
|
||||
# 2. 从各数据源聚合
|
||||
profile = await self._aggregate_profile(employee_id)
|
||||
|
||||
# 3. 写入缓存
|
||||
profile_dict = asdict(profile)
|
||||
profile_dict['last_updated'] = datetime.now().isoformat()
|
||||
await self.redis.setex(
|
||||
cache_key,
|
||||
self.cache_ttl,
|
||||
json.dumps(profile_dict)
|
||||
)
|
||||
|
||||
logger.debug(f"[EmployeeProfile] 画像已更新: {employee_id}")
|
||||
return profile
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[EmployeeProfile] 获取画像失败: {employee_id}, {e}")
|
||||
# 返回基础画像
|
||||
return EmployeeProfile(
|
||||
employee_id=employee_id,
|
||||
name='',
|
||||
department='',
|
||||
position='',
|
||||
mobile=''
|
||||
)
|
||||
|
||||
async def _aggregate_profile(self, employee_id: str) -> EmployeeProfile:
|
||||
"""
|
||||
从多个数据源聚合员工画像
|
||||
"""
|
||||
# 并行获取各数据源
|
||||
wecom_task = self._get_wecom_profile(employee_id)
|
||||
unionsoft_task = self._get_unionsoft_status(employee_id)
|
||||
huorong_task = self._get_huorong_status(employee_id)
|
||||
|
||||
try:
|
||||
wecom, unionsoft, huorong = await asyncio.gather(
|
||||
wecom_task,
|
||||
unionsoft_task,
|
||||
huorong_task,
|
||||
return_exceptions=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[EmployeeProfile] 并行获取失败: {e}")
|
||||
wecom, unionsoft, huorong = {}, {}, {}
|
||||
|
||||
return EmployeeProfile(
|
||||
employee_id=employee_id,
|
||||
name=wecom.get('name', ''),
|
||||
department=wecom.get('department', ''),
|
||||
position=wecom.get('position', ''),
|
||||
mobile=wecom.get('mobile', ''),
|
||||
unionsoft_patches_missing=unionsoft.get('patches_missing', 0),
|
||||
unionsoft_last_scan=unionsoft.get('last_scan'),
|
||||
unionsoft_violations=unionsoft.get('violations', []),
|
||||
huorong_version=huorong.get('version', ''),
|
||||
huorong_virusdb_date=huorong.get('virusdb_date'),
|
||||
huorong_offline_days=huorong.get('offline_days', 0)
|
||||
)
|
||||
|
||||
async def _get_wecom_profile(self, employee_id: str) -> Dict:
|
||||
"""从企微获取员工基本信息"""
|
||||
try:
|
||||
# 简化实现,实际应调用企微 API
|
||||
# 这里返回空字典,实际使用时接入企微通讯录 API
|
||||
logger.debug(f"[EmployeeProfile] 企微画像查询: {employee_id}")
|
||||
return {
|
||||
'name': '',
|
||||
'department': '',
|
||||
'position': '',
|
||||
'mobile': ''
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"[EmployeeProfile] 企微 API 调用失败: {e}")
|
||||
return {}
|
||||
|
||||
async def _get_unionsoft_status(self, employee_id: str) -> Dict:
|
||||
"""从联软获取终端安全状态"""
|
||||
try:
|
||||
# 简化实现,实际应调用联软 API
|
||||
# 尝试查询终端信息
|
||||
client = self.lianruan_client
|
||||
|
||||
# 根据员工姓名/账号查询终端
|
||||
# 这里需要根据实际业务逻辑调整
|
||||
terminals = await client.query_dev_by_params(strusername=employee_id)
|
||||
|
||||
if not terminals:
|
||||
return {
|
||||
'patches_missing': 0,
|
||||
'last_scan': None,
|
||||
'violations': []
|
||||
}
|
||||
|
||||
# 取第一个终端的信息
|
||||
terminal = terminals[0]
|
||||
|
||||
# 获取终端详情
|
||||
detail = await client.get_dev_all_info(strdevname=terminal.device_name)
|
||||
|
||||
return {
|
||||
'patches_missing': getattr(detail, 'patches_missing', 0) or 0,
|
||||
'last_scan': getattr(detail, 'last_scan_time', None),
|
||||
'violations': getattr(detail, 'violations', []) or []
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[EmployeeProfile] 联软 API 调用失败: {e}")
|
||||
return {
|
||||
'patches_missing': 0,
|
||||
'last_scan': None,
|
||||
'violations': []
|
||||
}
|
||||
|
||||
async def _get_huorong_status(self, employee_id: str) -> Dict:
|
||||
"""从火绒获取终端状态"""
|
||||
try:
|
||||
client = self.huorong_client
|
||||
|
||||
# 根据员工账号查询终端
|
||||
terminals = await client.list_terminals()
|
||||
|
||||
# 找到匹配的终端(通常按设备名或账号匹配)
|
||||
matched = None
|
||||
for t in terminals:
|
||||
if employee_id in str(t.device_name) or employee_id in str(t.account):
|
||||
matched = t
|
||||
break
|
||||
|
||||
if not matched:
|
||||
return {
|
||||
'version': '',
|
||||
'virusdb_date': None,
|
||||
'offline_days': 0
|
||||
}
|
||||
|
||||
# 获取终端详情
|
||||
detail = await client.get_terminal_info2(matched.id)
|
||||
|
||||
# 计算离线天数
|
||||
offline_days = 0
|
||||
if hasattr(detail, 'last_online_time') and detail.last_online_time:
|
||||
try:
|
||||
last_online = datetime.fromisoformat(
|
||||
detail.last_online_time.replace('Z', '+00:00')
|
||||
)
|
||||
offline_days = (datetime.now() - last_online.replace(tzinfo=None)).days
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
'version': getattr(detail, 'agent_version', ''),
|
||||
'virusdb_date': getattr(detail, 'virus_db_date', None),
|
||||
'offline_days': offline_days
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[EmployeeProfile] 火绒 API 调用失败: {e}")
|
||||
return {
|
||||
'version': '',
|
||||
'virusdb_date': None,
|
||||
'offline_days': 0
|
||||
}
|
||||
|
||||
async def clear_expired_cache(self) -> int:
|
||||
"""清理过期缓存"""
|
||||
try:
|
||||
cursor = 0
|
||||
deleted = 0
|
||||
while True:
|
||||
cursor, keys = await self.redis.scan(
|
||||
cursor=cursor,
|
||||
match='employee_profile:*',
|
||||
count=100
|
||||
)
|
||||
if keys:
|
||||
deleted += await self.redis.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
logger.info(f"[EmployeeProfile] 清理过期缓存: {deleted} 条")
|
||||
return deleted
|
||||
except Exception as e:
|
||||
logger.error(f"[EmployeeProfile] 清理缓存失败: {e}")
|
||||
return 0
|
||||
|
||||
async def invalidate_cache(self, employee_id: str) -> None:
|
||||
"""使指定员工的缓存失效"""
|
||||
try:
|
||||
cache_key = f"employee_profile:{employee_id}"
|
||||
await self.redis.delete(cache_key)
|
||||
logger.debug(f"[EmployeeProfile] 缓存已失效: {employee_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[EmployeeProfile] 缓存失效失败: {employee_id}, {e}")
|
||||
|
||||
|
||||
# 全局单例
|
||||
_employee_profile_service: Optional[EmployeeProfileService] = None
|
||||
|
||||
|
||||
def get_employee_profile_service() -> EmployeeProfileService:
|
||||
"""获取员工画像服务单例"""
|
||||
global _employee_profile_service
|
||||
if _employee_profile_service is None:
|
||||
_employee_profile_service = EmployeeProfileService()
|
||||
return _employee_profile_service
|
||||
@@ -0,0 +1,193 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 图谱查询服务
|
||||
# =============================================================================
|
||||
# 说明:基于Neo4j知识图谱的智能问答服务。
|
||||
# 用户问题 → 图谱查询 → 直接返回解决方案(毫秒级响应)
|
||||
# 未命中 → 降级到Dify流程
|
||||
#
|
||||
# 核心功能:
|
||||
# 1. 关键词提取(jieba分词 + 停用词过滤)
|
||||
# 2. Neo4j模糊匹配Issue节点
|
||||
# 3. 查找关联的Action解决方案
|
||||
# 4. 返回最高匹配的SolutionResult
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 停用词列表(常见的无意义词汇)
|
||||
STOPWORDS = {
|
||||
"的", "了", "在", "是", "我", "有", "和", "就", "不", "人",
|
||||
"都", "一", "一个", "上", "也", "很", "到", "说", "要", "去",
|
||||
"你", "会", "着", "没有", "看", "好", "自己", "这", "那",
|
||||
"什么", "怎么", "如何", "为什么", "请问", "帮忙", "帮助",
|
||||
}
|
||||
|
||||
|
||||
class SolutionResult(BaseModel):
|
||||
"""图谱查询结果 - 解决方案"""
|
||||
|
||||
issue_name: str = ""
|
||||
issue_uuid: str = ""
|
||||
solution: str = ""
|
||||
action_name: str = ""
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
class GraphQueryService:
|
||||
"""图谱查询服务 - 根据用户问题查找解决方案"""
|
||||
|
||||
def __init__(self, neo4j_client):
|
||||
"""初始化图谱查询服务
|
||||
|
||||
Args:
|
||||
neo4j_client: Neo4jClient实例
|
||||
"""
|
||||
self.neo4j_client = neo4j_client
|
||||
self._keyword_cache: dict = {} # 简单缓存
|
||||
|
||||
async def find_solution_by_question(
|
||||
self, question: str, timeout_ms: int = 100
|
||||
) -> Optional[SolutionResult]:
|
||||
"""根据用户问题查找解决方案(图谱查询主入口)
|
||||
|
||||
流程:
|
||||
1. 提取问题关键词
|
||||
2. 遍历关键词查询Neo4j图谱
|
||||
3. 获取匹配的Issue及关联的Action
|
||||
4. 返回置信度最高的解决方案
|
||||
|
||||
Args:
|
||||
question: 用户问题文本
|
||||
timeout_ms: 查询超时时间(毫秒),默认100ms
|
||||
|
||||
Returns:
|
||||
Optional[SolutionResult]: 匹配的解决方案,未命中返回None
|
||||
"""
|
||||
if not question or not question.strip():
|
||||
return None
|
||||
|
||||
# 1. 提取关键词
|
||||
keywords = self._extract_keywords(question)
|
||||
if not keywords:
|
||||
logger.info(f"图谱查询:无法提取关键词 question={question[:50]}")
|
||||
return None
|
||||
|
||||
logger.info(f"图谱查询:keywords={keywords}, question={question[:50]}")
|
||||
|
||||
# 2. 遍历关键词查询图谱(按置信度排序)
|
||||
for kw in keywords:
|
||||
try:
|
||||
result = await self._query_by_keyword(kw)
|
||||
if result:
|
||||
logger.info(
|
||||
f"图谱命中: keyword={kw}, issue={result.issue_name}, "
|
||||
f"confidence={result.confidence}"
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"图谱查询异常(降级继续): keyword={kw}, error={e}")
|
||||
continue
|
||||
|
||||
logger.info(f"图谱未命中: question={question[:50]}")
|
||||
return None
|
||||
|
||||
def _extract_keywords(self, text: str) -> List[str]:
|
||||
"""从文本中提取关键词
|
||||
|
||||
使用jieba分词 + 停用词过滤
|
||||
|
||||
Args:
|
||||
text: 输入文本
|
||||
|
||||
Returns:
|
||||
List[str]: 关键词列表(按出现顺序)
|
||||
"""
|
||||
# 尝试导入jieba,如果失败则使用简单分词
|
||||
try:
|
||||
import jieba
|
||||
words = jieba.lcut(text)
|
||||
except ImportError:
|
||||
# 降级:简单按空格和标点分词
|
||||
words = text.replace(",", " ").replace("?", " ").replace("?", " ").split()
|
||||
|
||||
# 过滤停用词和短词
|
||||
keywords = [
|
||||
w.strip()
|
||||
for w in words
|
||||
if w.strip() and len(w.strip()) >= 2 and w.strip() not in STOPWORDS
|
||||
]
|
||||
|
||||
# 返回前5个关键词(避免查询过多)
|
||||
return keywords[:5]
|
||||
|
||||
async def _query_by_keyword(self, keyword: str) -> Optional[SolutionResult]:
|
||||
"""根据单个关键词查询解决方案
|
||||
|
||||
Args:
|
||||
keyword: 搜索关键词
|
||||
|
||||
Returns:
|
||||
Optional[SolutionResult]: 匹配的解决方案
|
||||
"""
|
||||
# 调用neo4j_client的模糊搜索方法
|
||||
if not hasattr(self.neo4j_client, "find_issues_by_keyword"):
|
||||
logger.warning("neo4j_client没有find_issues_by_keyword方法")
|
||||
return None
|
||||
|
||||
issues = await self.neo4j_client.find_issues_by_keyword(keyword, limit=5)
|
||||
if not issues:
|
||||
return None
|
||||
|
||||
# 获取第一个Issue的详细信息和关联的Action
|
||||
issue = issues[0]
|
||||
|
||||
# 查询关联的Action解决方案
|
||||
actions = await self.neo4j_client.find_actions_by_issue(issue.uuid)
|
||||
if not actions:
|
||||
return None
|
||||
|
||||
action = actions[0]
|
||||
|
||||
# 构建返回结果
|
||||
return SolutionResult(
|
||||
issue_name=issue.name,
|
||||
issue_uuid=issue.uuid,
|
||||
solution=action.description or action.name,
|
||||
action_name=action.name,
|
||||
confidence=0.8, # 简化处理,默认0.8
|
||||
)
|
||||
|
||||
|
||||
# 全局单例
|
||||
_graph_query_service: Optional[GraphQueryService] = None
|
||||
|
||||
|
||||
async def get_graph_query_service(neo4j_client=None) -> Optional[GraphQueryService]:
|
||||
"""获取GraphQueryService单例(异步版本)
|
||||
|
||||
Args:
|
||||
neo4j_client: Neo4jClient实例,不传则自动获取
|
||||
|
||||
Returns:
|
||||
Optional[GraphQueryService]: 图谱查询服务实例,Neo4j不可用时返回None
|
||||
"""
|
||||
global _graph_query_service
|
||||
|
||||
if _graph_query_service is None:
|
||||
if neo4j_client is None:
|
||||
from app.services.neo4j_client import get_neo4j_client
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
# 如果neo4j_client为None(图服务不可用),直接返回None
|
||||
if neo4j_client is None:
|
||||
return None
|
||||
|
||||
_graph_query_service = GraphQueryService(neo4j_client)
|
||||
|
||||
return _graph_query_service
|
||||
@@ -0,0 +1,182 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 知识库导入图谱脚本
|
||||
# =============================================================================
|
||||
# 说明:将KnowledgeBase表中的问题-答案映射批量导入Neo4j图谱
|
||||
#
|
||||
# 功能:
|
||||
# 1. 读取knowledge_base表数据
|
||||
# 2. 批量创建Issue和Action节点
|
||||
# 3. 建立LEADS_TO关系
|
||||
#
|
||||
# 使用方式:
|
||||
# python -m app.services.import_knowledge_to_graph
|
||||
# =============================================================================
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import _get_session_factory
|
||||
from app.models.knowledge_base import KnowledgeBase
|
||||
from app.models.neo4j_schema import ActionNode, IssueNode
|
||||
from app.services.neo4j_client import Neo4jClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KnowledgeImporter:
|
||||
"""知识库导入器 - 将SQL知识库数据导入Neo4j图谱"""
|
||||
|
||||
def __init__(self, neo4j_client: Neo4jClient):
|
||||
self.neo4j_client = neo4j_client
|
||||
|
||||
async def import_all(self, batch_size: int = 50) -> dict:
|
||||
"""批量导入所有知识库数据到图谱
|
||||
|
||||
Args:
|
||||
batch_size: 每批处理数量,默认50
|
||||
|
||||
Returns:
|
||||
dict: 导入统计 {issue_count, action_count, relation_count, errors}
|
||||
"""
|
||||
stats = {
|
||||
"issue_count": 0,
|
||||
"action_count": 0,
|
||||
"relation_count": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
factory = _get_session_factory()
|
||||
async with factory() as db:
|
||||
# 读取所有知识库数据
|
||||
stmt = select(KnowledgeBase).where(
|
||||
KnowledgeBase.category.isnot(None)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
all_knowledge = result.scalars().all()
|
||||
|
||||
logger.info(f"开始导入知识库,共 {len(all_knowledge)} 条记录")
|
||||
|
||||
# 按类别分组导入
|
||||
category_groups = {}
|
||||
for kb in all_knowledge:
|
||||
cat = kb.category or "其他"
|
||||
if cat not in category_groups:
|
||||
category_groups[cat] = []
|
||||
category_groups[cat].append(kb)
|
||||
|
||||
# 逐类导入
|
||||
for category, items in category_groups.items():
|
||||
logger.info(f"导入分类: {category}, 共 {len(items)} 条")
|
||||
|
||||
for kb in items:
|
||||
try:
|
||||
# 创建Issue节点
|
||||
issue = await self._import_issue(kb)
|
||||
if not issue:
|
||||
continue
|
||||
|
||||
# 创建Action节点
|
||||
action = await self._import_action(kb)
|
||||
if not action:
|
||||
continue
|
||||
|
||||
# 建立关系
|
||||
await self._create_leads_to(issue.uuid, action.uuid)
|
||||
|
||||
stats["issue_count"] += 1
|
||||
stats["action_count"] += 1
|
||||
stats["relation_count"] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"导入失败: title={kb.title}, error={e}"
|
||||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
|
||||
logger.info(f"导入完成: {stats}")
|
||||
return stats
|
||||
|
||||
async def _import_issue(self, kb: KnowledgeBase) -> IssueNode:
|
||||
"""导入Issue节点
|
||||
|
||||
Args:
|
||||
kb: KnowledgeBase记录
|
||||
|
||||
Returns:
|
||||
IssueNode: 创建的Issue节点
|
||||
"""
|
||||
return await self.neo4j_client.merge_issue(
|
||||
name=kb.title,
|
||||
category=kb.category or "其他",
|
||||
)
|
||||
|
||||
async def _import_action(self, kb: KnowledgeBase) -> ActionNode:
|
||||
"""导入Action节点
|
||||
|
||||
Args:
|
||||
kb: KnowledgeBase记录
|
||||
|
||||
Returns:
|
||||
ActionNode: 创建的Action节点
|
||||
"""
|
||||
# 使用title作为action名称,content作为描述
|
||||
action_name = f"{kb.title}_解决方案"
|
||||
return await self.neo4j_client.merge_action(
|
||||
name=action_name,
|
||||
props={"description": kb.content or ""},
|
||||
)
|
||||
|
||||
async def _create_leads_to(self, issue_uuid: str, action_uuid: str) -> bool:
|
||||
"""建立Issue到Action的LEADS_TO关系
|
||||
|
||||
Args:
|
||||
issue_uuid: Issue节点UUID
|
||||
action_uuid: Action节点UUID
|
||||
|
||||
Returns:
|
||||
bool: 是否创建成功
|
||||
"""
|
||||
from app.models.neo4j_schema import RelationEdge
|
||||
|
||||
rel = RelationEdge(type="LEADS_TO", order=1, weight=1.0)
|
||||
return await self.neo4j_client.create_relation(
|
||||
from_uuid=issue_uuid,
|
||||
to_uuid=action_uuid,
|
||||
rel=rel,
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
"""主入口函数"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# 初始化Neo4j客户端
|
||||
neo4j_client = Neo4jClient()
|
||||
await neo4j_client.initialize()
|
||||
|
||||
try:
|
||||
# 执行导入
|
||||
importer = KnowledgeImporter(neo4j_client)
|
||||
stats = await importer.import_all()
|
||||
|
||||
print("\n========== 导入完成 ==========")
|
||||
print(f"Issue节点: {stats['issue_count']}")
|
||||
print(f"Action节点: {stats['action_count']}")
|
||||
print(f"关系数量: {stats['relation_count']}")
|
||||
print(f"错误数量: {len(stats['errors'])}")
|
||||
|
||||
if stats["errors"]:
|
||||
print("\n错误列表:")
|
||||
for err in stats["errors"][:10]: # 只显示前10条
|
||||
print(f" - {err}")
|
||||
|
||||
finally:
|
||||
# 关闭Neo4j连接
|
||||
await neo4j_client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -53,14 +53,22 @@ class ITHealthService:
|
||||
Returns:
|
||||
Dict: 包含 current_device / other_devices / data_source / health_score
|
||||
"""
|
||||
# 尝试从联软获取真实设备信息
|
||||
# 尝试从联软获取真实设备信息(总部员工优先)
|
||||
device_info = await self._get_device_from_lianruan(employee_id)
|
||||
|
||||
# 联软不可用 → 尝试从火绒获取设备信息(分公司员工兜底)
|
||||
if device_info is None:
|
||||
# 联软不可用 → 返回 Mock 数据
|
||||
device_info = await self._get_device_from_huorong(employee_id)
|
||||
|
||||
# 火绒不可用 → 尝试从设备清单数据库查询
|
||||
if device_info is None:
|
||||
device_info = await self._get_device_from_inventory(employee_id)
|
||||
|
||||
if device_info is None:
|
||||
# 联软/火绒/设备清单都不可用 → 返回 Mock 数据
|
||||
return self._get_mock_data(employee_id)
|
||||
|
||||
# 联软数据可用,尝试从火绒获取安全状态
|
||||
# 已有设备信息,尝试从火绒获取安全状态
|
||||
security_info = await self._get_security_from_huorong(
|
||||
device_info.get("device_name", "")
|
||||
)
|
||||
@@ -170,6 +178,124 @@ class ITHealthService:
|
||||
logger.warning(f"联软获取设备信息失败: {e}")
|
||||
return None
|
||||
|
||||
async def _get_device_from_inventory(self, employee_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""从设备清单数据库查询设备信息(联软/火绒不可用时的兜底方案)。
|
||||
|
||||
匹配逻辑:
|
||||
1. employee_account = employee_id(员工企微账号)
|
||||
2. employee_name = employee_id(员工姓名)
|
||||
3. computer_name 包含 employee_id
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID 或姓名
|
||||
|
||||
Returns:
|
||||
Dict: 设备信息字典,未匹配时返回 None
|
||||
"""
|
||||
try:
|
||||
# 直接用 SQL 查询
|
||||
result = await self.db.fetch("""
|
||||
SELECT computer_name, ip_address, mac_address,
|
||||
employee_account, employee_name, asset_tag, department, source
|
||||
FROM device_inventory
|
||||
WHERE employee_account = $1
|
||||
OR employee_name LIKE $2
|
||||
OR computer_name ILIKE $3
|
||||
LIMIT 1
|
||||
""", employee_id, f"%{employee_id}%", f"%{employee_id}%")
|
||||
|
||||
if not result:
|
||||
logger.info(f"设备清单中未找到员工 {employee_id} 的设备")
|
||||
return None
|
||||
|
||||
row = result[0]
|
||||
logger.info(f"从设备清单找到员工 {employee_id} 的设备: {row['computer_name']}")
|
||||
|
||||
return {
|
||||
"device_name": row["computer_name"],
|
||||
"ip_address": row["ip_address"],
|
||||
"mac_address": row["mac_address"],
|
||||
"employee_account": row["employee_account"],
|
||||
"employee_name": row["employee_name"],
|
||||
"asset_tag": row["asset_tag"],
|
||||
"department": row["department"],
|
||||
"source": row["source"],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"从设备清单查询失败: {e}")
|
||||
return None
|
||||
|
||||
async def _get_device_from_huorong(self, employee_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""从火绒查终端设备信息(联软不可用时的兜底方案)。
|
||||
|
||||
通过以下方式匹配员工设备:
|
||||
1. 计算机名匹配:员工账号作为 strusername 查询
|
||||
2. IP 匹配:查询所有终端,匹配员工当前 IP
|
||||
|
||||
Args:
|
||||
employee_id: 员工账号
|
||||
|
||||
Returns:
|
||||
Dict: 设备信息字典,火绒不可用时返回 None
|
||||
"""
|
||||
try:
|
||||
from app.integrations.huorong.config import get_huorong_client
|
||||
|
||||
client = await get_huorong_client(self.db)
|
||||
if not client:
|
||||
logger.warning("火绒客户端未配置")
|
||||
return None
|
||||
|
||||
# 方式1:尝试用员工账号匹配计算机名
|
||||
# 火绒终端的 computer_name 通常是 hostname
|
||||
result = await client.list_terminals(per_page=200)
|
||||
terminals = result.get("items", [])
|
||||
|
||||
matched_terminal = None
|
||||
|
||||
# 优先尝试精确匹配:计算机名包含员工账号
|
||||
for t in terminals:
|
||||
if t.computer_name and employee_id.lower() in t.computer_name.lower():
|
||||
matched_terminal = t
|
||||
logger.info(f"火绒精确匹配: {t.computer_name} (employee={employee_id})")
|
||||
break
|
||||
|
||||
# 如果没有精确匹配,返回第一个在线终端(兜底策略)
|
||||
if not matched_terminal:
|
||||
for t in terminals:
|
||||
if t.is_online:
|
||||
matched_terminal = t
|
||||
logger.info(f"火绒兜底匹配: {t.computer_name} (employee={employee_id}, 在线终端)")
|
||||
break
|
||||
|
||||
if not matched_terminal:
|
||||
logger.info(f"火绒未找到任何终端")
|
||||
return None
|
||||
|
||||
# 获取终端详细信息
|
||||
detail = await client.get_terminal_detail(matched_terminal.client_id)
|
||||
|
||||
# 构建设备信息字典
|
||||
device = {
|
||||
"device_name": matched_terminal.computer_name or "",
|
||||
"is_online": matched_terminal.is_online,
|
||||
"ip_address": matched_terminal.local_ip or "",
|
||||
"mac": getattr(matched_terminal, "mac", "") or "",
|
||||
"os": detail.os_name or "",
|
||||
"location": "",
|
||||
"department": "",
|
||||
"uptime": "",
|
||||
"last_online_time": "",
|
||||
}
|
||||
|
||||
logger.info(f"火绒获取设备成功: {matched_terminal.computer_name} (employee={employee_id})")
|
||||
return device
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"火绒获取设备信息失败: {e}")
|
||||
return None
|
||||
|
||||
async def _get_other_devices(
|
||||
self, employee_id: str, exclude_device: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -392,6 +392,7 @@ class MessageRouter:
|
||||
"urgency_score": conversation.urgency_score,
|
||||
"tags": conversation.tags,
|
||||
"ai_replied": True,
|
||||
"extra_data": extra_data if extra_data else {},
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
|
||||
@@ -30,6 +30,26 @@ from app.models.neo4j_schema import ActionNode, IssueNode, RelationEdge
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_python_datetime(value: Any) -> Optional[datetime]:
|
||||
"""将 Neo4j 返回的 datetime 转换为 Python datetime。
|
||||
|
||||
处理 Neo4j 的 neo4j.time.DateTime 类型,转换为 Python datetime。
|
||||
如果无法转换或值为 None,返回 None。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
# Neo4j 返回的是 neo4j.time.DateTime 对象
|
||||
if hasattr(value, 'to_native'): # neo4j.time.DateTime
|
||||
try:
|
||||
return value.to_native()
|
||||
except Exception:
|
||||
return None
|
||||
# 已经是 Python datetime
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
class Neo4jClient:
|
||||
"""Neo4j 图数据库异步客户端。
|
||||
|
||||
@@ -167,9 +187,9 @@ class Neo4jClient:
|
||||
if self._driver is None:
|
||||
return False
|
||||
try:
|
||||
# execute_read_query 已经返回数据列表,不需要再迭代
|
||||
result = await self.execute_read_query("RETURN 1 AS ok")
|
||||
records = [record async for record in result]
|
||||
return len(records) > 0 and records[0].get("ok") == 1
|
||||
return len(result) > 0 and result[0].get("ok") == 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Neo4j 健康检查失败: {e}")
|
||||
return False
|
||||
@@ -290,8 +310,8 @@ class Neo4jClient:
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
category=record["category"],
|
||||
created_at=record["created_at"],
|
||||
updated_at=record["updated_at"],
|
||||
created_at=_to_python_datetime(record["created_at"]),
|
||||
updated_at=_to_python_datetime(record["updated_at"]),
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
|
||||
@@ -344,8 +364,8 @@ class Neo4jClient:
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
category=record["category"],
|
||||
created_at=record["created_at"],
|
||||
updated_at=record["updated_at"],
|
||||
created_at=_to_python_datetime(record["created_at"]),
|
||||
updated_at=_to_python_datetime(record["updated_at"]),
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
|
||||
@@ -375,8 +395,8 @@ class Neo4jClient:
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
category=record["category"],
|
||||
created_at=record["created_at"],
|
||||
updated_at=record["updated_at"],
|
||||
created_at=_to_python_datetime(record["created_at"]),
|
||||
updated_at=_to_python_datetime(record["updated_at"]),
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
|
||||
@@ -409,8 +429,55 @@ class Neo4jClient:
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
category=record["category"],
|
||||
created_at=record["created_at"],
|
||||
updated_at=record["updated_at"],
|
||||
created_at=_to_python_datetime(record["created_at"]),
|
||||
updated_at=_to_python_datetime(record["updated_at"]),
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
for record in data
|
||||
]
|
||||
|
||||
async def find_issues_by_keyword(
|
||||
self, keyword: str, limit: int = 10
|
||||
) -> List[IssueNode]:
|
||||
"""根据关键词模糊搜索Issue节点(图谱查询核心方法)
|
||||
|
||||
使用 Cypher CONTAINS 进行模糊匹配,支持中文关键词搜索。
|
||||
|
||||
Args:
|
||||
keyword: 搜索关键词
|
||||
limit: 返回结果数量限制,默认10
|
||||
|
||||
Returns:
|
||||
List[IssueNode]: 匹配的Issue节点列表
|
||||
"""
|
||||
if not keyword or not keyword.strip():
|
||||
return []
|
||||
|
||||
# 修复:只使用正确的 CONTAINS 语法
|
||||
# Neo4j CONTAINS: i.name CONTAINS $keyword 表示 Issue名称 包含 关键词
|
||||
keywords = keyword.strip()
|
||||
|
||||
# 单向匹配:Issue名称包含关键词("打印机驱动" 匹配 "打印机驱动安装")
|
||||
cypher_where = "i.name CONTAINS $keyword"
|
||||
|
||||
data = await self.execute_read_query(
|
||||
f"""
|
||||
MATCH (i:Issue)
|
||||
WHERE {cypher_where}
|
||||
RETURN i.uuid AS uuid, i.name AS name, i.category AS category,
|
||||
i.created_at AS created_at, i.updated_at AS updated_at,
|
||||
i.source_suggestion_id AS source_suggestion_id
|
||||
LIMIT $limit
|
||||
""",
|
||||
params={"keyword": keywords, "limit": limit},
|
||||
)
|
||||
return [
|
||||
IssueNode(
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
category=record["category"],
|
||||
created_at=_to_python_datetime(record["created_at"]),
|
||||
updated_at=_to_python_datetime(record["updated_at"]),
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
for record in data
|
||||
@@ -450,7 +517,7 @@ class Neo4jClient:
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
description=record.get("description", ""),
|
||||
created_at=record["created_at"],
|
||||
created_at=_to_python_datetime(record["created_at"]),
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
|
||||
@@ -494,10 +561,45 @@ class Neo4jClient:
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
description=record.get("description", ""),
|
||||
created_at=record["created_at"],
|
||||
created_at=_to_python_datetime(record["created_at"]),
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
|
||||
async def find_actions_by_issue(
|
||||
self, issue_uuid: str, limit: int = 5
|
||||
) -> List[ActionNode]:
|
||||
"""根据Issue UUID查找关联的Action解决方案(图谱查询核心方法)
|
||||
|
||||
查询与指定Issue节点通过LEADS_TO关系关联的Action解决方案。
|
||||
|
||||
Args:
|
||||
issue_uuid: Issue节点的uuid
|
||||
limit: 返回结果数量限制,默认5
|
||||
|
||||
Returns:
|
||||
List[ActionNode]: 关联的Action节点列表
|
||||
"""
|
||||
data = await self.execute_read_query(
|
||||
"""
|
||||
MATCH (i:Issue {uuid: $issue_uuid})-[r:HAS_ACTION|LEADS_TO]->(a:Action)
|
||||
RETURN a.uuid AS uuid, a.name AS name,
|
||||
a.description AS description, a.created_at AS created_at,
|
||||
a.source_suggestion_id AS source_suggestion_id
|
||||
LIMIT $limit
|
||||
""",
|
||||
params={"issue_uuid": issue_uuid, "limit": limit},
|
||||
)
|
||||
return [
|
||||
ActionNode(
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
description=record.get("description", ""),
|
||||
created_at=_to_python_datetime(record["created_at"]),
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
for record in data
|
||||
]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 图关系 CRUD — RelationEdge
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,7 @@ from uuid import UUID
|
||||
from sqlalchemy import and_, case, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.services.avatar_service import clean_avatar_url, wrap_avatar_url
|
||||
@@ -1138,9 +1139,9 @@ class SessionService:
|
||||
if not (is_primary_agent or is_creator or is_participant):
|
||||
raise AppException(3030, "只有会话参与者才能邀请人员加入会话")
|
||||
|
||||
# 3. 校验:会话必须是服务中状态
|
||||
if conversation.status != "serving":
|
||||
raise AppException(3031, f"只有服务中的会话才能邀请,当前状态: {conversation.status}")
|
||||
# 3. 校验:会话必须是活跃状态(AI处理中或服务中)
|
||||
if conversation.status not in ("serving", "ai_handling"):
|
||||
raise AppException(3031, f"只有活跃状态的会话才能邀请(AI处理中或服务中),当前状态: {conversation.status}")
|
||||
|
||||
# 4. 合并参与者(去重),同时补充头像
|
||||
existing_participants = list(conversation.participants or [])
|
||||
@@ -1182,9 +1183,11 @@ class SessionService:
|
||||
if p.get("type") == "employee":
|
||||
try:
|
||||
# 生成邀请链接:H5 端加入会话的 URL
|
||||
# 格式:https://itsupport.servyou.com.cn/itdesk/?invite={conv_id}&eid={employee_id}
|
||||
# 格式:https://itsupport.servyou.com.cn/h5/?invite={conv_id}&eid={employee_id}
|
||||
# Bug 修复:使用 settings.wecom_sso_callback_base 构建绝对路径,
|
||||
# 企微卡片消息要求 URL 必须以 https:// 开头
|
||||
invite_url = (
|
||||
f"{getattr(self, '_h5_base_url', '')}/itdesk/"
|
||||
f"{settings.wecom_sso_callback_base.rstrip('/')}/h5/"
|
||||
f"?invite={conversation.id}&eid={p['id']}"
|
||||
)
|
||||
await self.wecom_service.send_card_message(
|
||||
@@ -1236,9 +1239,9 @@ class SessionService:
|
||||
# 1. 校验会话
|
||||
conversation = await self._get_conversation(conversation_id)
|
||||
|
||||
# 2. 校验:会话必须是服务中状态
|
||||
if conversation.status != "serving":
|
||||
raise AppException(3033, "该会话已结束,无法加入")
|
||||
# 2. 校验:会话必须是活跃状态(AI处理中或服务中)
|
||||
if conversation.status not in ("serving", "ai_handling"):
|
||||
raise AppException(3033, "该会话当前状态不允许加入")
|
||||
|
||||
# 3. 校验:该员工必须在 participants 列表中(被邀请过才能加入)
|
||||
participants = list(conversation.participants or [])
|
||||
|
||||
@@ -244,6 +244,80 @@ class TokenService:
|
||||
|
||||
logger.info(f"Token 已失效: {token[:10]}...")
|
||||
|
||||
async def record_token_ip(self, token: str, ip_address: str) -> None:
|
||||
"""记录Token使用的IP地址。
|
||||
|
||||
用于后续的异常检测(如同一Token多IP使用)。
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
ip_address: 客户端IP地址
|
||||
"""
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
# 计算token hash,避免存储明文token
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
|
||||
|
||||
# Redis Key: token_ip:token_hash -> 逗号分隔的IP列表
|
||||
key = f"token_ip:{token_hash}"
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# 追加新IP和访问时间
|
||||
# 格式: "ip1@2026-07-14T10:00:00,ip2@2026-07-14T10:05:00"
|
||||
existing = await self.redis.get(key)
|
||||
if existing:
|
||||
existing_str = existing.decode("utf-8") if isinstance(existing, bytes) else existing
|
||||
# 只保留最近1小时的记录
|
||||
entries = existing_str.split(",")
|
||||
from datetime import timedelta
|
||||
one_hour_ago = datetime.now() - timedelta(hours=1)
|
||||
filtered = []
|
||||
for entry in entries:
|
||||
if "@" in entry:
|
||||
ip, ts_str = entry.rsplit("@", 1)
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str)
|
||||
if ts > one_hour_ago:
|
||||
filtered.append(entry)
|
||||
except ValueError:
|
||||
pass
|
||||
filtered.append(f"{ip_address}@{timestamp}")
|
||||
new_value = ",".join(filtered[-50:]) # 最多保留50条
|
||||
else:
|
||||
new_value = f"{ip_address}@{timestamp}"
|
||||
|
||||
# 保留1小时
|
||||
await self.redis.setex(key, 3600, new_value)
|
||||
logger.debug(f"记录Token IP: token_hash={token_hash}, ip={ip_address}")
|
||||
|
||||
async def get_token_ips(self, token: str) -> List[Dict]:
|
||||
"""获取Token使用的IP列表。
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
|
||||
Returns:
|
||||
List[Dict]: [{"ip": "x.x.x.x", "timestamp": "..."}]
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
|
||||
key = f"token_ip:{token_hash}"
|
||||
|
||||
data = await self.redis.get(key)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
data_str = data.decode("utf-8") if isinstance(data, bytes) else data
|
||||
result = []
|
||||
for entry in data_str.split(","):
|
||||
if "@" in entry:
|
||||
ip, ts_str = entry.rsplit("@", 1)
|
||||
result.append({"ip": ip, "timestamp": ts_str})
|
||||
|
||||
return result
|
||||
|
||||
def _get_default_role(self, roles: List[str]) -> str:
|
||||
"""获取默认角色。
|
||||
|
||||
|
||||
@@ -1325,6 +1325,54 @@ class WecomService:
|
||||
logger.error(f"上传临时素材网络错误: type={media_type}, error={e}")
|
||||
raise Exception(f"上传临时素材网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 下载临时素材
|
||||
# --------------------------------------------------------------------------
|
||||
async def download_temp_media(self, media_id: str) -> bytes:
|
||||
"""下载临时素材(图片/文件/语音),返回二进制数据。
|
||||
|
||||
对应企微API:
|
||||
GET https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=TOKEN&media_id=MEDIA_ID
|
||||
|
||||
用于将企微回调中的图片/文件下载到本地服务器保存。
|
||||
|
||||
Args:
|
||||
media_id: 媒体文件ID(企微回调中的 MediaId)
|
||||
|
||||
Returns:
|
||||
bytes: 媒体文件的二进制数据
|
||||
|
||||
Raises:
|
||||
Exception: 下载失败
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/media/get"
|
||||
params = {
|
||||
"access_token": access_token,
|
||||
"media_id": media_id,
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info(f"开始下载临时素材: media_id={media_id}")
|
||||
response = await self.client.get(url, params=params)
|
||||
|
||||
# 检查返回的是否是JSON错误响应
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
result = response.json()
|
||||
if result.get("errcode") != 0:
|
||||
errmsg = result.get("errmsg", "未知错误")
|
||||
logger.error(f"下载临时素材失败: media_id={media_id}, errcode={result.get('errcode')}, errmsg={errmsg}")
|
||||
raise Exception(f"下载临时素材失败: {errmsg}")
|
||||
|
||||
# 返回二进制数据
|
||||
logger.info(f"下载临时素材成功: media_id={media_id}, size={len(response.content)} bytes")
|
||||
return response.content
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"下载临时素材网络错误: media_id={media_id}, error={e}")
|
||||
raise Exception(f"下载临时素材网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# OAuth2 授权换算用户身份
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -54,6 +54,59 @@ class WingmanService:
|
||||
"输出格式:{\"suggested_tags\": [\"标签1\", \"标签2\"], \"category\": \"分类\", \"priority\": \"low/medium/high\"}"
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# AI 辅助消息框 Prompt(4 个功能,与 PRD v1.0 对齐)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_AUTOCOMPLETE_SYSTEM_PROMPT: str = (
|
||||
"你是一个IT服务坐席输入助手。根据坐席当前正在输入的内容和对话上下文,补齐下一句话。\n"
|
||||
"要求:\n"
|
||||
"1. 补齐内容自然衔接当前文字,不要重复已有内容\n"
|
||||
"2. 长度控制在1-2个短句,不超过80字\n"
|
||||
"3. 语气专业、简洁,符合IT服务规范\n"
|
||||
"4. 只返回补齐的文字,不要加引号或其他标记"
|
||||
)
|
||||
|
||||
_TONE_ADJUST_SYSTEM_PROMPT: str = (
|
||||
"你是一个IT服务话术改写助手。将坐席选中的文字改写为指定风格。\n\n"
|
||||
"语气定义:\n"
|
||||
"- 专业(professional):使用准确的技术术语,结构化表达,去除口语化内容\n"
|
||||
"- 友好(friendly):适当增加问候和关心语,语气更亲和\n"
|
||||
"- 简洁(concise):去除冗余,直奔主题,控制字数\n\n"
|
||||
"要求:\n"
|
||||
"1. 保持原意不变,只调整语气和表达方式\n"
|
||||
"2. 改写后的文字长度与原文相近(±30%)\n"
|
||||
"3. 符合IT服务坐席的专业规范\n"
|
||||
"4. 以JSON格式输出,包含 rewritten_text、tone、changes_summary 三个字段\n"
|
||||
"5. changes_summary 简述所做的修改"
|
||||
)
|
||||
|
||||
_POLISH_SYSTEM_PROMPT: str = (
|
||||
"你是一个IT服务文字精修助手。对坐席输入的文字进行指定操作的处理。\n\n"
|
||||
"操作定义:\n"
|
||||
"- 扩写(expand):在原文基础上增加操作步骤、注意事项、解释说明,使回复更完整\n"
|
||||
"- 压缩(compress):精简表达,去除重复和冗余,保留核心信息,控制字数\n"
|
||||
"- 纠错(correct):检查并修正错别字、语法错误、标点符号、格式问题\n\n"
|
||||
"要求:\n"
|
||||
"1. 保持原意不变\n"
|
||||
"2. 以JSON格式输出,包含 polished_text、action、changes_summary 三个字段\n"
|
||||
"3. changes_summary 简述所做的修改"
|
||||
)
|
||||
|
||||
_REWRITE_SYSTEM_PROMPT: str = (
|
||||
"你是一个IT服务智能回复助手。基于对话上下文和知识库,为坐席生成3个不同风格的备选回复。\n\n"
|
||||
"版本要求:\n"
|
||||
"- 版本1:简洁直接,一句话说明问题和解决方案\n"
|
||||
"- 版本2:详细带步骤,包含操作步骤和注意事项\n"
|
||||
"- 版本3:带知识库引用,引用相关文档并给出权威解决方案\n\n"
|
||||
"要求:\n"
|
||||
"1. 3个版本内容不重复,各有侧重\n"
|
||||
"2. 每个版本独立成段,用 --- 分隔(三个减号,独占一行)\n"
|
||||
"3. 只返回回复内容,不要加额外解释\n\n"
|
||||
"输出格式:\n"
|
||||
"版本1简洁内容\n---\n版本2详细内容\n---\n版本3知识库引用内容"
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 知识建议生成专用 Prompt(Tier0 / T03 — 通道 A/B 复用)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -82,11 +135,14 @@ class WingmanService:
|
||||
"\"parent_issue\": \"网络问题\"}"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, redis_client=None):
|
||||
"""初始化 Wingman 服务。
|
||||
|
||||
从配置读取 Wingman Agent 的 API 地址和认证信息。
|
||||
独立于 AIService,使用自己的 httpx 客户端。
|
||||
|
||||
Args:
|
||||
redis_client: Redis 异步客户端(可选,用于补齐结果缓存)
|
||||
"""
|
||||
# Wingman Agent 专用 API 端点
|
||||
self.api_url = settings.dify_wingman_api_url
|
||||
@@ -98,6 +154,9 @@ class WingmanService:
|
||||
# httpx 异步客户端(复用连接池)
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
# Redis 客户端(可选,用于补齐结果缓存)
|
||||
self._redis = redis_client
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""获取或创建 httpx 异步客户端(懒加载)。
|
||||
|
||||
@@ -292,6 +351,270 @@ class WingmanService:
|
||||
logger.error(f"Wingman 标签建议失败: {e}")
|
||||
return default_tags
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# AI 辅助消息框:4 个核心方法
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def generate_completion(
|
||||
self,
|
||||
conversation_id: str,
|
||||
current_text: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
max_length: int = 80,
|
||||
) -> Dict[str, Any]:
|
||||
"""自动补齐。
|
||||
|
||||
根据坐席当前输入和对话上下文,生成下一句的补齐建议。
|
||||
使用 Redis 缓存(TTL=30s)减少重复请求。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
current_text: 坐席当前输入的文本
|
||||
messages: 会话消息历史列表
|
||||
max_length: 补齐建议最大长度(默认 80 字符)
|
||||
|
||||
Returns:
|
||||
Dict: {"completion": str, "confidence": float}
|
||||
"""
|
||||
# 1. 检查 Redis 缓存
|
||||
cache_key = self._make_cache_key(current_text, conversation_id)
|
||||
cached = await self._get_cache(cache_key)
|
||||
if cached:
|
||||
logger.info(f"自动补齐缓存命中: key={cache_key}")
|
||||
return cached
|
||||
|
||||
# 2. 构建上下文消息
|
||||
context = self._build_context_messages(
|
||||
messages, self._AUTOCOMPLETE_SYSTEM_PROMPT
|
||||
)
|
||||
context.append({
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"坐席正在输入:{current_text}\n"
|
||||
f"请补齐下一句话(不超过{max_length}字)"
|
||||
),
|
||||
})
|
||||
|
||||
# 3. 调用 Dify(降低 temperature 提高准确性)
|
||||
try:
|
||||
result = await self._call_wingman_api(context, temperature=0.2)
|
||||
if result is None:
|
||||
return {"completion": "", "confidence": 0.0}
|
||||
|
||||
completion = result.strip()
|
||||
confidence = self._estimate_confidence(completion)
|
||||
|
||||
response = {"completion": completion, "confidence": confidence}
|
||||
|
||||
# 4. 写入 Redis 缓存(TTL=30s)
|
||||
if self._redis:
|
||||
await self._set_cache(cache_key, response, ttl=30)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"自动补齐失败: {e}")
|
||||
return {"completion": "", "confidence": 0.0}
|
||||
|
||||
async def adjust_tone(
|
||||
self,
|
||||
conversation_id: str,
|
||||
selected_text: str,
|
||||
full_text: str,
|
||||
tone: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""语气调整。
|
||||
|
||||
将坐席选中的文字改写为指定语气风格(专业/友好/简洁)。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
selected_text: 选中的文字
|
||||
full_text: 输入框完整内容(供 AI 理解上下文)
|
||||
tone: 目标语气:professional/friendly/concise
|
||||
messages: 会话消息历史列表
|
||||
|
||||
Returns:
|
||||
Dict: {"rewritten_text": str, "tone": str, "changes_summary": str}
|
||||
"""
|
||||
tone_label = {"professional": "专业", "friendly": "友好", "concise": "简洁"}
|
||||
|
||||
context = self._build_context_messages(
|
||||
messages, self._TONE_ADJUST_SYSTEM_PROMPT
|
||||
)
|
||||
context.append({
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"目标语气:{tone_label.get(tone, tone)}\n"
|
||||
f"原文:{selected_text}\n"
|
||||
f"完整输入框内容(供上下文参考):{full_text}\n"
|
||||
f"请将原文改写为{tone_label.get(tone, tone)}风格。"
|
||||
),
|
||||
})
|
||||
|
||||
try:
|
||||
result = await self._call_wingman_api(context, temperature=0.3)
|
||||
if result is None:
|
||||
return {
|
||||
"rewritten_text": "",
|
||||
"tone": tone,
|
||||
"changes_summary": "AI 服务暂不可用",
|
||||
}
|
||||
|
||||
parsed = self._parse_json_response(result, {})
|
||||
return {
|
||||
"rewritten_text": parsed.get("rewritten_text", result.strip()),
|
||||
"tone": tone,
|
||||
"changes_summary": parsed.get("changes_summary", "已完成语气调整"),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"语气调整失败: {e}")
|
||||
return {
|
||||
"rewritten_text": "",
|
||||
"tone": tone,
|
||||
"changes_summary": "AI 服务暂不可用",
|
||||
}
|
||||
|
||||
async def polish_text(
|
||||
self,
|
||||
conversation_id: str,
|
||||
text: str,
|
||||
action: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""文字润色。
|
||||
|
||||
对坐席输入的文字进行扩写/压缩/纠错处理。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
text: 待润色文字
|
||||
action: 润色操作:expand(扩写)/compress(压缩)/correct(纠错)
|
||||
messages: 会话消息历史列表
|
||||
|
||||
Returns:
|
||||
Dict: {"polished_text": str, "action": str, "changes_summary": str}
|
||||
"""
|
||||
action_label = {"expand": "扩写", "compress": "压缩", "correct": "纠错"}
|
||||
|
||||
context = self._build_context_messages(
|
||||
messages, self._POLISH_SYSTEM_PROMPT
|
||||
)
|
||||
context.append({
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"操作类型:{action_label.get(action, action)}\n"
|
||||
f"原文:{text}\n"
|
||||
f"请对原文进行{action_label.get(action, action)}处理。"
|
||||
),
|
||||
})
|
||||
|
||||
try:
|
||||
result = await self._call_wingman_api(context, temperature=0.3)
|
||||
if result is None:
|
||||
return {
|
||||
"polished_text": "",
|
||||
"action": action,
|
||||
"changes_summary": "AI 服务暂不可用",
|
||||
}
|
||||
|
||||
parsed = self._parse_json_response(result, {})
|
||||
return {
|
||||
"polished_text": parsed.get("polished_text", result.strip()),
|
||||
"action": action,
|
||||
"changes_summary": parsed.get("changes_summary", "已完成润色"),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"文字润色失败: {e}")
|
||||
return {
|
||||
"polished_text": "",
|
||||
"action": action,
|
||||
"changes_summary": "AI 服务暂不可用",
|
||||
}
|
||||
|
||||
async def rewrite_versions(
|
||||
self,
|
||||
conversation_id: str,
|
||||
current_text: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
generate_count: int = 3,
|
||||
include_knowledge: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""智能改写。
|
||||
|
||||
基于对话上下文和知识库,生成多个不同风格的备选回复。
|
||||
版本1: 简洁直接 / 版本2: 详细带步骤 / 版本3: 带知识库引用。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
current_text: 当前输入文本(可为空)
|
||||
messages: 会话消息历史列表
|
||||
generate_count: 生成版本数(默认 3)
|
||||
include_knowledge: 是否包含知识库引用版本(默认 True)
|
||||
|
||||
Returns:
|
||||
Dict: {"versions": [{"text": str, "style": str, "source": str}, ...]}
|
||||
"""
|
||||
context = self._build_context_messages(
|
||||
messages, self._REWRITE_SYSTEM_PROMPT
|
||||
)
|
||||
|
||||
# 知识库检索(版本3)
|
||||
knowledge_snippets = ""
|
||||
if include_knowledge:
|
||||
query = current_text or " ".join(
|
||||
[m.get("content", "") for m in messages[-3:]
|
||||
])
|
||||
kb_result = await self._search_knowledge(query)
|
||||
if kb_result:
|
||||
knowledge_snippets = kb_result
|
||||
logger.info(f"改写知识库检索成功: {len(kb_result)} 字符")
|
||||
else:
|
||||
logger.info("改写知识库检索为空,降级为仅对话上下文")
|
||||
|
||||
user_content = (
|
||||
f"当前输入:{current_text or '无(请基于对话上下文生成)'}\n"
|
||||
)
|
||||
if knowledge_snippets:
|
||||
user_content += f"\n知识库参考资料:\n{knowledge_snippets}\n"
|
||||
user_content += "\n请生成3个不同风格的备选回复。"
|
||||
|
||||
context.append({"role": "user", "content": user_content})
|
||||
|
||||
try:
|
||||
result = await self._call_wingman_api(context, temperature=0.6)
|
||||
if result is None:
|
||||
return {"versions": []}
|
||||
|
||||
# 按 --- 分割版本
|
||||
parts = [p.strip() for p in result.split("\n---\n") if p.strip()]
|
||||
|
||||
style_map = [
|
||||
("简洁直接", "对话上下文"),
|
||||
("详细带步骤", "对话上下文"),
|
||||
(
|
||||
"带知识库引用",
|
||||
"RAGFlow知识库" if knowledge_snippets else "对话上下文",
|
||||
),
|
||||
]
|
||||
|
||||
versions = []
|
||||
for i, text in enumerate(parts[:generate_count]):
|
||||
if i < len(style_map):
|
||||
style, source = style_map[i]
|
||||
else:
|
||||
style, source = f"版本{i+1}", "对话上下文"
|
||||
versions.append({"text": text, "style": style, "source": source})
|
||||
|
||||
return {"versions": versions}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"智能改写失败: {e}")
|
||||
return {"versions": []}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 核心方法 4:生成知识库优化建议(Tier0 / T03 — 复用现有范式)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -435,11 +758,14 @@ class WingmanService:
|
||||
async def _call_wingman_api(
|
||||
self,
|
||||
context_messages: List[Dict[str, str]],
|
||||
temperature: float = 0.3,
|
||||
) -> Optional[str]:
|
||||
"""调用 Wingman Agent API(非流式)。
|
||||
|
||||
Args:
|
||||
context_messages: OpenAI 格式的消息列表
|
||||
temperature: 温度参数(0-1),默认 0.3。不同功能使用不同值:
|
||||
补齐 0.2 / 语气调整 0.3 / 润色 0.3 / 改写 0.6
|
||||
|
||||
Returns:
|
||||
Optional[str]: AI 回复内容,失败时返回 None
|
||||
@@ -448,7 +774,7 @@ class WingmanService:
|
||||
"model": "Chat",
|
||||
"messages": context_messages,
|
||||
"stream": False,
|
||||
"temperature": 0.3, # 适中的温度,保证准确性同时有一定灵活性
|
||||
"temperature": temperature, # 参数化温度,保持向后兼容(默认 0.3)
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -566,3 +892,106 @@ class WingmanService:
|
||||
|
||||
# 限制在 0.0 - 1.0 范围内
|
||||
return max(0.0, min(1.0, confidence))
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# AI 辅助消息框:私有辅助方法
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _make_cache_key(self, text: str, conversation_id: str) -> str:
|
||||
"""生成 Redis 缓存 key。
|
||||
|
||||
格式: wingman:autocomplete:{md5(text + conversation_id)}
|
||||
对输入文本 + 会话ID 做 MD5 哈希,避免 key 过长。
|
||||
|
||||
Args:
|
||||
text: 输入文本
|
||||
conversation_id: 会话ID
|
||||
|
||||
Returns:
|
||||
str: Redis 缓存 key
|
||||
"""
|
||||
import hashlib
|
||||
raw = f"{text}:{conversation_id}"
|
||||
digest = hashlib.md5(raw.encode()).hexdigest()
|
||||
return f"wingman:autocomplete:{digest}"
|
||||
|
||||
async def _get_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""读取 Redis 缓存。
|
||||
|
||||
Redis 不可用时返回 None(降级),不阻塞主流程。
|
||||
|
||||
Args:
|
||||
key: 缓存 key
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: 缓存的补齐结果,不存在或失败时返回 None
|
||||
"""
|
||||
if self._redis is None:
|
||||
return None
|
||||
try:
|
||||
data = await self._redis.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 缓存读取失败: {e}")
|
||||
return None
|
||||
|
||||
async def _set_cache(
|
||||
self, key: str, value: Dict[str, Any], ttl: int = 30
|
||||
) -> None:
|
||||
"""写入 Redis 缓存。
|
||||
|
||||
Redis 不可用时静默跳过(降级),不阻塞主流程。
|
||||
|
||||
Args:
|
||||
key: 缓存 key
|
||||
value: 缓存值(会序列化为 JSON)
|
||||
ttl: 过期时间(秒),默认 30 秒
|
||||
"""
|
||||
if self._redis is None:
|
||||
return
|
||||
try:
|
||||
await self._redis.setex(
|
||||
key, ttl, json.dumps(value, ensure_ascii=False)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 缓存写入失败: {e}")
|
||||
|
||||
async def _search_knowledge(self, query: str) -> Optional[str]:
|
||||
"""调用 RAGFlow 检索知识库。
|
||||
|
||||
用于改写版本3,从知识库获取参考资料。
|
||||
检索失败时返回 None(降级),不影响其他版本生成。
|
||||
|
||||
Args:
|
||||
query: 检索查询(基于对话上下文)
|
||||
|
||||
Returns:
|
||||
Optional[str]: 合并后的知识片段文本,失败或无结果时返回 None
|
||||
"""
|
||||
try:
|
||||
from app.integrations.factory import build_ragflow_client
|
||||
|
||||
client = await build_ragflow_client()
|
||||
if client is None:
|
||||
logger.warning("RAGFlow 客户端不可用")
|
||||
return None
|
||||
|
||||
# 使用默认知识库检索(不指定 dataset_ids 则使用全局)
|
||||
result = await client.retrieval(
|
||||
question=query,
|
||||
similarity_threshold=0.2,
|
||||
top_k=3,
|
||||
)
|
||||
|
||||
if result and result.chunks:
|
||||
snippets = "\n\n".join([
|
||||
f"[{c.document_keyword or '未知文档'}] {c.content[:500]}"
|
||||
for c in result.chunks[:3]
|
||||
])
|
||||
return snippets
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"RAGFlow 检索失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user