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
|