2026-07-17 23:08:59 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 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)
|
2026-07-18 01:10:18 +08:00
|
|
|
|
# 5. 兜底:全部失败时返回全量 18 项卡片(v4.0 P1-4,不再返回 None)
|
2026-07-17 23:08:59 +08:00
|
|
|
|
#
|
|
|
|
|
|
# 输出:标准化卡片数据 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 正常路径)。
|
2026-07-18 01:10:18 +08:00
|
|
|
|
|
2026-07-17 23:08:59 +08:00
|
|
|
|
Args:
|
|
|
|
|
|
approval_type: Dify 返回的审批类型(可能是 ID、中文分类名或具体名称)
|
|
|
|
|
|
title: Dify 返回的具体审批名称(如"VPN账号申请")
|
2026-07-18 01:10:18 +08:00
|
|
|
|
|
2026-07-17 23:08:59 +08:00
|
|
|
|
Returns:
|
2026-07-18 01:10:18 +08:00
|
|
|
|
标准化 card_data,或 None(无 approval_type 时)
|
|
|
|
|
|
v4.0 P1-4:匹配失败不再返回 None,兜底返回全量卡片
|
2026-07-17 23:08:59 +08:00
|
|
|
|
"""
|
|
|
|
|
|
if not approval_type:
|
|
|
|
|
|
return None
|
2026-07-18 01:10:18 +08:00
|
|
|
|
|
2026-07-17 23:08:59 +08:00
|
|
|
|
# 优先级 1:精确 ID 匹配
|
|
|
|
|
|
template = self._match_by_id(approval_type)
|
|
|
|
|
|
if template:
|
|
|
|
|
|
return self._build_single_card(template)
|
2026-07-18 01:10:18 +08:00
|
|
|
|
|
2026-07-17 23:08:59 +08:00
|
|
|
|
# 优先级 2:关键词匹配
|
|
|
|
|
|
template = self._match_by_keyword(approval_type)
|
|
|
|
|
|
if template:
|
|
|
|
|
|
return self._build_single_card(template)
|
2026-07-18 01:10:18 +08:00
|
|
|
|
|
2026-07-17 23:08:59 +08:00
|
|
|
|
# 优先级 3:category 匹配
|
|
|
|
|
|
options = self._match_by_category(approval_type)
|
|
|
|
|
|
if options:
|
|
|
|
|
|
return self._build_multi_card(approval_type, options)
|
2026-07-18 01:10:18 +08:00
|
|
|
|
|
|
|
|
|
|
# 优先级 4:title 匹配(先关键词后名称,title 是具体名称如"VPN账号申请")
|
2026-07-17 23:08:59 +08:00
|
|
|
|
if title:
|
2026-07-18 01:10:18 +08:00
|
|
|
|
template = self._match_by_keyword(title) or self._match_by_name(title)
|
2026-07-17 23:08:59 +08:00
|
|
|
|
if template:
|
|
|
|
|
|
return self._build_single_card(template)
|
2026-07-18 01:10:18 +08:00
|
|
|
|
|
|
|
|
|
|
# 优先级 5(v4.0 P1-4):兜底返回全量卡片
|
|
|
|
|
|
# 原死分支(重复调用 _match_by_keyword)已删除——与优先级 2 数学上等价不可达
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"[ApprovalMatcher] 匹配失败,兜底全量卡片: "
|
|
|
|
|
|
f"approval_type={approval_type}, title={title}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return self.build_all_categories_card()
|
|
|
|
|
|
|
|
|
|
|
|
def build_all_categories_card(self) -> dict:
|
|
|
|
|
|
"""构建全量 18 项审批卡片(匹配失败兜底 + 快捷申请入口共用)。"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"card_type": "multiple",
|
|
|
|
|
|
"title": "全部审批类型",
|
|
|
|
|
|
"description": "请选择需要提交的审批",
|
|
|
|
|
|
"options": [
|
|
|
|
|
|
self._template_to_option(t)
|
|
|
|
|
|
for t in APPROVAL_TEMPLATES.values()
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
2026-07-17 23:08:59 +08:00
|
|
|
|
|
|
|
|
|
|
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
|