feat(agent): 添加技能自动路由功能 - 引入 SkillRouter 实现根据用户消息自动推荐技能 - 在构建引擎时集成技能路由器 - 从对话历史中提取最后一条用户消息作为路由输入 - 为技能添加触发关键词和中文标题字段支持 refactor(browser): 重构浏览器自动化为子进程架构 - 将 Playwright 浏览器控制移至独立的子进程 worker - 解决 PyInstaller 打包环境下 C 扩展兼容性问题 - 通过 JSON RPC 协议与浏览器 worker 通信 - 添加工具目录和脚本路径查找机制 feat(skills): 增强技能元数据和UI展示 - 为技能添加 triggers 和 title 字段 - 在技能商店中包含中文标题信息 - 添加 office-viz 技能优先级排序 - 在服务器管理器中返回技能标题 feat(gui): 实现技能选择器UI组件 - 添加带下拉菜单的技能选择器按钮 - 支持中文标题和拼音首字母显示 - 集成会话技能加载和状态管理 - 提供通用技能选项和已启用技能列表 ```
303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""
|
||
技能路由器 (Skill Router)
|
||
========================
|
||
|
||
根据用户消息的关键词,自动匹配最合适的 Skill,并推荐给 Agent 加载。
|
||
|
||
设计原则:
|
||
- 每个 Skill 在 SKILL.md 的 frontmatter 里声明 triggers 字段(逗号分隔的关键词)
|
||
- 路由器读取所有 skill 的 triggers,与用户消息做匹配
|
||
- 匹配度超过阈值的 skill 会被推荐给 Agent
|
||
- 用户新添加的 skill 只要声明了 triggers,自动参与路由,无需修改代码
|
||
|
||
匹配算法:
|
||
- 关键词命中数(加权)+ 描述相似度 + 场景上下文
|
||
- 返回按置信度排序的推荐列表
|
||
|
||
调试:
|
||
- 设置环境变量 SKILL_ROUTER_LOG=1 可输出调试日志到 workspace/skill_router.log
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
|
||
@dataclass
|
||
class SkillMatch:
|
||
"""技能匹配结果"""
|
||
name: str
|
||
confidence: float # 0.0 - 1.0
|
||
matched_keywords: list[str]
|
||
reason: str = ""
|
||
|
||
|
||
class SkillRouter:
|
||
"""技能路由器"""
|
||
|
||
# 置信度阈值:超过这个值才会推荐
|
||
RECOMMEND_THRESHOLD = 0.18
|
||
|
||
# 强推荐阈值:超过这个值会强烈建议加载
|
||
STRONG_RECOMMEND_THRESHOLD = 0.35
|
||
|
||
# 自动预加载阈值:超过这个值直接把技能指令注入上下文
|
||
# 模型会认为这个技能已经加载了,直接按照指令执行
|
||
PRELOAD_THRESHOLD = 0.35
|
||
|
||
def __init__(self, loader) -> None:
|
||
"""
|
||
Args:
|
||
loader: SkillLoader 实例
|
||
"""
|
||
self._loader = loader
|
||
|
||
def match(self, user_message: str) -> list[SkillMatch]:
|
||
"""
|
||
匹配用户消息,返回推荐的技能列表(按置信度降序)
|
||
|
||
Args:
|
||
user_message: 用户的最新消息文本
|
||
|
||
Returns:
|
||
匹配结果列表,每个元素是 SkillMatch
|
||
"""
|
||
if not user_message or len(user_message.strip()) == 0:
|
||
return []
|
||
|
||
msg = user_message.lower()
|
||
results: list[SkillMatch] = []
|
||
|
||
for skill_name in self._loader.names():
|
||
skill = self._loader.get(skill_name)
|
||
if skill is None:
|
||
continue
|
||
|
||
score = 0.0
|
||
matched = []
|
||
|
||
# 1. 触发词匹配(权重最高)
|
||
triggers = getattr(skill, "triggers", [])
|
||
if triggers:
|
||
for trigger in triggers:
|
||
trigger_lower = trigger.lower()
|
||
if len(trigger_lower) < 2:
|
||
continue
|
||
|
||
# 精确包含匹配(权重高)
|
||
if trigger_lower in msg:
|
||
score += 0.20
|
||
matched.append(trigger)
|
||
continue
|
||
|
||
# 子序列匹配:所有字都在消息里且顺序一致(不一定连续)
|
||
# 中文里插入少量字是常见的(如"做个图表"≈"做图表"),权重较高
|
||
if self._is_subsequence(trigger_lower, msg):
|
||
# 触发词越长且插入字越少,权重越高
|
||
insert_ratio = len(trigger_lower) / max(len(msg), 1)
|
||
weight = 0.10 + 0.08 * insert_ratio
|
||
score += weight
|
||
matched.append(trigger + "(顺序)")
|
||
continue
|
||
|
||
# 部分匹配:触发词有 2 个字以上连续出现在消息里
|
||
hit_len = self._max_common_substring_len(trigger_lower, msg)
|
||
if hit_len >= 3:
|
||
weight = 0.05 + 0.02 * (hit_len - 3)
|
||
score += weight
|
||
matched.append(trigger + f"(部分{hit_len}字)")
|
||
|
||
# 2. 描述匹配(次要权重)
|
||
desc = skill.description.lower()
|
||
desc_keywords = self._extract_keywords(desc)
|
||
for kw in desc_keywords:
|
||
if len(kw) >= 2 and kw in msg:
|
||
score += 0.05
|
||
if kw not in matched:
|
||
matched.append(kw)
|
||
|
||
# 3. 名称匹配(小权重)
|
||
if skill_name.lower() in msg:
|
||
score += 0.1
|
||
matched.append(skill_name)
|
||
|
||
# 归一化到 0-1
|
||
confidence = min(score, 1.0)
|
||
|
||
if confidence >= self.RECOMMEND_THRESHOLD:
|
||
reason = self._build_reason(skill_name, confidence, matched)
|
||
results.append(SkillMatch(
|
||
name=skill_name,
|
||
confidence=confidence,
|
||
matched_keywords=matched,
|
||
reason=reason,
|
||
))
|
||
|
||
# 按置信度降序
|
||
results.sort(key=lambda x: x.confidence, reverse=True)
|
||
return results
|
||
|
||
def build_context_text(self, user_message: str) -> str:
|
||
"""
|
||
生成注入到系统提示的路由推荐文本
|
||
|
||
如果匹配度足够高(超过 PRELOAD_THRESHOLD),直接把技能的完整指令
|
||
注入上下文,模型会认为技能已加载,直接按指令执行。
|
||
|
||
Args:
|
||
user_message: 用户最新消息
|
||
|
||
Returns:
|
||
推荐文本,如果没有匹配则返回空字符串
|
||
"""
|
||
if not user_message or len(user_message.strip()) == 0:
|
||
return ""
|
||
|
||
matches = self.match(user_message)
|
||
|
||
# 调试日志
|
||
self._log_match(user_message, matches)
|
||
|
||
if not matches:
|
||
return ""
|
||
|
||
strong = [m for m in matches if m.confidence >= self.STRONG_RECOMMEND_THRESHOLD]
|
||
preload = [m for m in matches if m.confidence >= self.PRELOAD_THRESHOLD]
|
||
normal = [m for m in matches if self.RECOMMEND_THRESHOLD <= m.confidence < self.STRONG_RECOMMEND_THRESHOLD]
|
||
|
||
lines = []
|
||
|
||
# 自动预加载:高置信度时直接注入技能完整指令
|
||
if preload:
|
||
top = preload[0]
|
||
skill = self._loader.get(top.name)
|
||
if skill and skill.instructions:
|
||
lines.append(f"🎯 自动技能路由: 已为你预加载「{skill.name}」技能")
|
||
lines.append(f" 匹配原因: {top.reason}")
|
||
lines.append(f" 置信度: {top.confidence:.0%}")
|
||
lines.append("")
|
||
lines.append(f"=== 技能: {skill.name} ===")
|
||
lines.append("以下是该技能的完整说明,请直接按照说明执行:")
|
||
lines.append("")
|
||
lines.append(skill.instructions)
|
||
lines.append("")
|
||
lines.append("=== 技能说明结束 ===")
|
||
lines.append("")
|
||
lines.append(
|
||
"📋 执行提示: 请使用上面预加载的技能来完成用户需求。"
|
||
"如果该技能有工具调用方式,请按说明调用。"
|
||
"不要使用 Python 脚本或其他方式重新实现该技能已有的功能。"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
# 低于预加载阈值时,只做推荐
|
||
lines.append("💡 Skill 推荐(根据用户消息自动匹配):")
|
||
|
||
if strong:
|
||
lines.append(" 🌟 强烈推荐:")
|
||
for m in strong:
|
||
lines.append(f" - {m.name} → {m.reason}")
|
||
lines.append(f" ⚡ 请立即调用 load_skill(\"{m.name}\") 加载此技能")
|
||
lines.append(f" 不要用基础工具手动实现,直接使用该技能")
|
||
|
||
if normal:
|
||
lines.append(" 📌 可能相关:")
|
||
for m in normal[:3]:
|
||
lines.append(f" - {m.name} → {m.reason}")
|
||
lines.append(f" 如需使用可调用 load_skill(\"{m.name}\")")
|
||
|
||
if strong:
|
||
lines.append(
|
||
"\n ⚡ 重要提示: 用户需求明确匹配上面的强烈推荐技能,"
|
||
"请立即调用 load_skill 加载该技能,然后按照技能说明执行。"
|
||
"不要尝试用 Python 脚本或其他方式手动实现。"
|
||
)
|
||
|
||
return "\n".join(lines)
|
||
|
||
def _extract_keywords(self, text: str) -> list[str]:
|
||
"""从文本中提取有意义的关键词(简单实现:提取中文词组和英文单词)"""
|
||
# 提取英文单词(长度 >= 3)
|
||
english_words = re.findall(r'[a-zA-Z]{3,}', text.lower())
|
||
|
||
# 提取中文词组(2-6 字的连续中文字符)
|
||
chinese_phrases = re.findall(r'[\u4e00-\u9fa5]{2,6}', text)
|
||
|
||
# 去掉常见停用词
|
||
stop_words = {
|
||
"的", "了", "和", "是", "在", "有", "与", "等", "将", "及",
|
||
"可以", "使用", "支持", "进行", "生成", "创建", "处理",
|
||
"数据", "文件", "功能", "工具", "技能", "输出", "输入",
|
||
"多种", "各种", "一个", "一些", "什么", "如何", "怎么",
|
||
}
|
||
keywords = []
|
||
for w in english_words + chinese_phrases:
|
||
if w not in stop_words and len(w) >= 2:
|
||
keywords.append(w)
|
||
|
||
# 去重并返回
|
||
return list(dict.fromkeys(keywords))
|
||
|
||
def _build_reason(self, name: str, confidence: float, matched: list[str]) -> str:
|
||
"""构建推荐理由文本"""
|
||
if matched:
|
||
show = matched[:4]
|
||
kw_text = "、".join(show)
|
||
if len(matched) > 4:
|
||
kw_text += " 等"
|
||
return f"匹配关键词「{kw_text}」"
|
||
return f"置信度 {confidence:.0%}"
|
||
|
||
def _log_match(self, user_message: str, matches: list[SkillMatch]) -> None:
|
||
"""输出调试日志(环境变量 SKILL_ROUTER_LOG=1 时启用)"""
|
||
if not os.environ.get("SKILL_ROUTER_LOG"):
|
||
return
|
||
try:
|
||
log_path = Path(os.environ.get("SKILL_ROUTER_LOG_PATH",
|
||
r"D:\project\senmeshworker-main\dist\openmesh\workspace\skill_router.log"))
|
||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
with open(log_path, "a", encoding="utf-8") as f:
|
||
f.write(f"\n[{ts}] 用户消息: {user_message[:100]}\n")
|
||
if matches:
|
||
f.write(f" 匹配到 {len(matches)} 个技能:\n")
|
||
for m in matches:
|
||
f.write(f" - {m.name}: {m.confidence:.1%} (关键词: {m.matched_keywords[:5]})\n")
|
||
if m.confidence >= self.PRELOAD_THRESHOLD:
|
||
f.write(f" → 自动预加载\n")
|
||
else:
|
||
f.write(" 无匹配\n")
|
||
except Exception:
|
||
pass # 日志失败不影响主流程
|
||
|
||
@staticmethod
|
||
def _is_subsequence(needle: str, haystack: str) -> bool:
|
||
"""检查 needle 是否是 haystack 的子序列(字符顺序一致,可不连续)"""
|
||
it = iter(haystack)
|
||
return all(c in it for c in needle)
|
||
|
||
@staticmethod
|
||
def _max_common_substring_len(s1: str, s2: str) -> int:
|
||
"""返回两个字符串的最长公共子串长度"""
|
||
if not s1 or not s2:
|
||
return 0
|
||
m, n = len(s1), len(s2)
|
||
max_len = 0
|
||
# 用滚动数组,O(n) 空间
|
||
prev = [0] * (n + 1)
|
||
for i in range(1, m + 1):
|
||
curr = [0] * (n + 1)
|
||
for j in range(1, n + 1):
|
||
if s1[i - 1] == s2[j - 1]:
|
||
curr[j] = prev[j - 1] + 1
|
||
max_len = max(max_len, curr[j])
|
||
else:
|
||
curr[j] = 0
|
||
prev = curr
|
||
return max_len
|